fixed known bugs_new

This commit is contained in:
MOHAN 2026-06-29 09:10:05 +05:30
commit b2ca96066b
30 changed files with 2923 additions and 0 deletions

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
# ─── Server ──────────────────────────────────────────────────────────────────
HOST=0.0.0.0
PORT=8000
MCP_SERVER_NAME=mt-odoo-mcp
MCP_LOG_LEVEL=INFO
# ─── Security (generate strong random values for production) ─────────────────
# Used to sign API keys and session tokens — keep SECRET
SECRET_KEY=change-me-to-a-long-random-string-in-production
# Fernet encryption key for Odoo credentials stored in the DB.
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=change-me-to-a-fernet-key-in-production
# ─── Database ─────────────────────────────────────────────────────────────────
# SQLite path (relative to working directory, or absolute)
DATABASE_PATH=./mt_odoo_mcp.db
# ─── Delete Confirmation ─────────────────────────────────────────────────────
CONFIRM_TOKEN_TTL=300
# ─── Audit Log ───────────────────────────────────────────────────────────────
AUDIT_LOG_FILE=./audit.jsonl
AUDIT_LOG_ENABLED=true

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.pyo
.env
*.db
*.db-shm
*.db-wal
audit.jsonl
.venv/
venv/
dist/
build/
*.egg-info/
.pytest_cache/
.mypy_cache/

29
pyproject.toml Normal file
View File

@ -0,0 +1,29 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mt-odoo-mcp"
version = "1.0.0"
description = "Multi-tenant MCP server for Odoo — users self-register and manage their own Odoo instances via AI agents"
requires-python = ">=3.11"
dependencies = [
"mcp>=1.0.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"python-dotenv>=1.0.0",
"anyio>=4.4.0",
"fastapi>=0.111.0",
"uvicorn[standard]>=0.30.0",
"passlib[bcrypt]>=1.7.4",
"cryptography>=42.0.0",
]
[project.scripts]
mt-odoo-mcp = "mt_odoo_mcp.server:main"
[tool.hatch.build.targets.wheel]
packages = ["src/mt_odoo_mcp"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

View File

View File

@ -0,0 +1,196 @@
"""REST API routes — user signup, login, credential management."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, EmailStr
from typing import Optional
from ..auth.service import (
AuthError, signup, login, get_user_by_api_key,
upsert_odoo_credential, list_odoo_credentials,
delete_odoo_credential, regenerate_api_key,
)
from ..registry import refresh_conn_manager, evict_user
router = APIRouter(prefix="/api")
# ─── Request / Response models ────────────────────────────────────────────────
class SignupRequest(BaseModel):
email: EmailStr
password: str
# Optional: provide Odoo credentials right at signup
odoo_url: Optional[str] = None
odoo_database: Optional[str] = None
odoo_username: Optional[str] = None
odoo_credential: Optional[str] = None
odoo_is_api_key: bool = True
class LoginRequest(BaseModel):
email: EmailStr
password: str
class CredentialRequest(BaseModel):
instance_name: str = "default"
url: str
database: str
username: str
credential: str
is_api_key: bool = True
# ─── Auth dependency ──────────────────────────────────────────────────────────
def _require_auth(authorization: Optional[str] = Header(None)):
"""Extract and validate Bearer API key from Authorization header."""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header. Use: Bearer <api_key>")
api_key = authorization.removeprefix("Bearer ").strip()
user = get_user_by_api_key(api_key)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired API key.")
return user
# ─── Routes ───────────────────────────────────────────────────────────────────
@router.post("/signup", status_code=201)
def api_signup(body: SignupRequest):
"""Register a new user. Returns the API key (shown once — save it!)."""
try:
user, full_api_key = signup(body.email, body.password)
except AuthError as e:
raise HTTPException(status_code=400, detail=str(e))
# If Odoo credentials provided at signup, store them immediately
if body.odoo_url and body.odoo_database and body.odoo_username and body.odoo_credential:
try:
upsert_odoo_credential(
user_id=user.id,
instance_name="default",
url=body.odoo_url,
database_name=body.odoo_database,
odoo_username=body.odoo_username,
credential=body.odoo_credential,
is_api_key=body.odoo_is_api_key,
)
refresh_conn_manager(user.id)
except Exception as e:
# Don't fail signup if credential storage fails; user can add later
pass
return {
"message": "Account created successfully.",
"user_id": user.id,
"email": user.email,
"api_key": full_api_key,
"warning": "Save your API key — it will not be shown again.",
"mcp_connection": {
"how_to_connect": "Set this in your MCP client config",
"url": "http://<your-vps-host>:<port>/mcp/sse",
"header": f"Authorization: Bearer {full_api_key}",
},
}
@router.post("/login")
def api_login(body: LoginRequest):
"""Verify email + password. Returns account info (not the API key — use /api/api-key to regenerate)."""
try:
user = login(body.email, body.password)
except AuthError as e:
raise HTTPException(status_code=401, detail=str(e))
return {
"user_id": user.id,
"email": user.email,
"api_key_prefix": user.api_key_prefix + "...",
"created_at": user.created_at,
"message": "Login successful. Use your API key to connect MCP clients.",
}
@router.get("/me")
def api_me(authorization: Optional[str] = Header(None)):
user = _require_auth(authorization)
creds = list_odoo_credentials(user.id)
return {
"user_id": user.id,
"email": user.email,
"api_key_prefix": user.api_key_prefix + "...",
"created_at": user.created_at,
"odoo_instances": [
{"instance_name": c.instance_name, "url": c.url,
"database": c.database_name, "username": c.odoo_username,
"type": "api_key" if c.is_api_key else "password"}
for c in creds
],
}
@router.post("/api-key/regenerate")
def api_regen_key(authorization: Optional[str] = Header(None)):
"""Generate a new API key, invalidating the old one. Shown once."""
user = _require_auth(authorization)
new_key = regenerate_api_key(user.id)
return {
"api_key": new_key,
"warning": "Your old API key is now invalid. Save this new key — it will not be shown again.",
"mcp_connection": {
"url": "http://<your-vps-host>:<port>/mcp/sse",
"header": f"Authorization: Bearer {new_key}",
},
}
@router.post("/credentials")
def api_add_credential(body: CredentialRequest, authorization: Optional[str] = Header(None)):
"""Add or update an Odoo instance credential."""
user = _require_auth(authorization)
try:
cred = upsert_odoo_credential(
user_id=user.id,
instance_name=body.instance_name,
url=body.url,
database_name=body.database,
odoo_username=body.username,
credential=body.credential,
is_api_key=body.is_api_key,
)
refresh_conn_manager(user.id)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"success": True,
"instance_name": cred.instance_name,
"url": cred.url,
"database": cred.database_name,
"username": cred.odoo_username,
"message": f"Odoo instance '{cred.instance_name}' saved.",
}
@router.get("/credentials")
def api_list_credentials(authorization: Optional[str] = Header(None)):
user = _require_auth(authorization)
creds = list_odoo_credentials(user.id)
return {
"instances": [
{"instance_name": c.instance_name, "url": c.url,
"database": c.database_name, "username": c.odoo_username,
"type": "api_key" if c.is_api_key else "password",
"created_at": c.created_at}
for c in creds
],
"total": len(creds),
}
@router.delete("/credentials/{instance_name}")
def api_delete_credential(instance_name: str, authorization: Optional[str] = Header(None)):
user = _require_auth(authorization)
deleted = delete_odoo_credential(user.id, instance_name)
if not deleted:
raise HTTPException(status_code=404, detail=f"Instance '{instance_name}' not found.")
refresh_conn_manager(user.id)
return {"success": True, "message": f"Instance '{instance_name}' removed."}

View File

View File

@ -0,0 +1,80 @@
from __future__ import annotations
import json
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from ..config import settings
_SENSITIVE = {"password", "api_key", "token", "secret", "passwd", "credential"}
def _sanitize(obj: Any, depth: int = 0) -> Any:
if depth > 5:
return obj
if isinstance(obj, dict):
return {
k: "***" if k.lower() in _SENSITIVE else _sanitize(v, depth + 1)
for k, v in obj.items()
}
if isinstance(obj, list):
return [_sanitize(i, depth + 1) for i in obj]
return obj
class AuditLogger:
_instance: Optional["AuditLogger"] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._lock = threading.Lock()
return cls._instance
def log(
self,
tool: str,
params: dict,
result_summary: str,
instance: str = "default",
user_id: Optional[int] = None,
user_email: Optional[str] = None,
success: bool = True,
error: Optional[str] = None,
):
if not settings.audit_log_enabled:
return
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"user_email": user_email,
"tool": tool,
"instance": instance,
"success": success,
"params": _sanitize(params),
"result": result_summary,
"error": error,
}
path = Path(settings.audit_log_file)
with self._lock:
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
def read_recent(self, n: int = 50, user_id: Optional[int] = None) -> list[dict]:
path = Path(settings.audit_log_file)
if not path.exists():
return []
with self._lock:
lines = path.read_text(encoding="utf-8").strip().splitlines()
entries = []
for line in lines:
try:
e = json.loads(line)
if user_id is None or e.get("user_id") == user_id:
entries.append(e)
except json.JSONDecodeError:
pass
return list(reversed(entries[-n:]))
audit_logger = AuditLogger()

View File

View File

@ -0,0 +1,52 @@
"""Password hashing, API key generation, and Fernet encryption for credentials."""
from __future__ import annotations
import secrets
from passlib.context import CryptContext
from cryptography.fernet import Fernet
from ..config import settings
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
def generate_api_key() -> tuple[str, str, str]:
"""Returns (full_key, key_hash, key_prefix).
full_key is shown to the user exactly once.
key_hash is stored in the DB.
key_prefix (first 8 chars) is stored for display.
"""
raw = secrets.token_hex(32)
full_key = f"mtom_{raw}"
key_hash = _pwd_context.hash(full_key)
key_prefix = full_key[:12]
return full_key, key_hash, key_prefix
def verify_api_key(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
def _fernet() -> Fernet:
key = settings.encryption_key
if key == "change-me":
raise RuntimeError("ENCRYPTION_KEY is not set. Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"")
try:
return Fernet(key.encode())
except Exception:
raise RuntimeError("ENCRYPTION_KEY is not a valid Fernet key.")
def encrypt_credential(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def decrypt_credential(enc: str) -> str:
return _fernet().decrypt(enc.encode()).decode()

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass
class User:
id: int
email: str
api_key_prefix: str
is_active: bool
created_at: str
@dataclass
class OdooCredential:
id: int
user_id: int
instance_name: str
url: str
database_name: str
odoo_username: str
credential_enc: str
is_api_key: bool
created_at: str

View File

@ -0,0 +1,189 @@
"""Auth business logic — signup, login, API key management, credential CRUD."""
from __future__ import annotations
from typing import Optional
from .models import User, OdooCredential
from .crypto import (
hash_password, verify_password,
generate_api_key, verify_api_key,
encrypt_credential, decrypt_credential,
)
from ..database import db_execute, db_commit, db_fetchone, db_fetchall
from ..config import OdooInstanceConfig as _Cfg
class AuthError(Exception):
pass
# ─── OdooInstanceConfig re-export for connection layer ────────────────────────
class OdooInstanceConfig:
def __init__(self, name: str, url: str, db: str, username: str, credential: str):
self.name = name
self.url = url
self.db = db
self.username = username
self._credential = credential
@property
def credential(self) -> str:
return self._credential
# ─── Users ────────────────────────────────────────────────────────────────────
def signup(email: str, password: str) -> tuple[User, str]:
"""Create a new user. Returns (user, full_api_key). API key shown only once."""
email = email.strip().lower()
if not email or not password:
raise AuthError("Email and password are required.")
if len(password) < 8:
raise AuthError("Password must be at least 8 characters.")
existing = db_fetchone("SELECT id FROM users WHERE email = ?", (email,))
if existing:
raise AuthError("An account with this email already exists.")
pw_hash = hash_password(password)
full_key, key_hash, key_prefix = generate_api_key()
db_execute(
"INSERT INTO users (email, password_hash, api_key_hash, api_key_prefix) VALUES (?, ?, ?, ?)",
(email, pw_hash, key_hash, key_prefix),
)
db_commit()
row = db_fetchone("SELECT * FROM users WHERE email = ?", (email,))
user = _row_to_user(row)
return user, full_key
def login(email: str, password: str) -> User:
"""Verify email + password. Returns the user or raises AuthError."""
email = email.strip().lower()
row = db_fetchone("SELECT * FROM users WHERE email = ?", (email,))
if not row or not verify_password(password, row["password_hash"]):
raise AuthError("Invalid email or password.")
if not row["is_active"]:
raise AuthError("Account is disabled.")
return _row_to_user(row)
def get_user_by_api_key(api_key: str) -> Optional[User]:
"""Look up a user by their API key. Returns None if not found/invalid."""
if not api_key or not api_key.startswith("mtom_"):
return None
prefix = api_key[:12]
rows = db_fetchall("SELECT * FROM users WHERE api_key_prefix = ? AND is_active = 1", (prefix,))
for row in rows:
if verify_api_key(api_key, row["api_key_hash"]):
return _row_to_user(row)
return None
def regenerate_api_key(user_id: int) -> str:
"""Generate a new API key for the user. Returns the full key (shown once)."""
full_key, key_hash, key_prefix = generate_api_key()
db_execute(
"UPDATE users SET api_key_hash = ?, api_key_prefix = ? WHERE id = ?",
(key_hash, key_prefix, user_id),
)
db_commit()
return full_key
def get_user_by_id(user_id: int) -> Optional[User]:
row = db_fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
return _row_to_user(row) if row else None
def _row_to_user(row) -> User:
return User(
id=row["id"],
email=row["email"],
api_key_prefix=row["api_key_prefix"],
is_active=bool(row["is_active"]),
created_at=row["created_at"],
)
# ─── Odoo Credentials ─────────────────────────────────────────────────────────
def upsert_odoo_credential(
user_id: int,
instance_name: str,
url: str,
database_name: str,
odoo_username: str,
credential: str,
is_api_key: bool = True,
) -> OdooCredential:
"""Add or replace an Odoo instance credential for a user."""
enc = encrypt_credential(credential)
existing = db_fetchone(
"SELECT id FROM odoo_credentials WHERE user_id = ? AND instance_name = ?",
(user_id, instance_name),
)
if existing:
db_execute(
"""UPDATE odoo_credentials
SET url=?, database_name=?, odoo_username=?, credential_enc=?, is_api_key=?
WHERE user_id=? AND instance_name=?""",
(url, database_name, odoo_username, enc, int(is_api_key), user_id, instance_name),
)
else:
db_execute(
"""INSERT INTO odoo_credentials
(user_id, instance_name, url, database_name, odoo_username, credential_enc, is_api_key)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user_id, instance_name, url, database_name, odoo_username, enc, int(is_api_key)),
)
db_commit()
row = db_fetchone(
"SELECT * FROM odoo_credentials WHERE user_id=? AND instance_name=?",
(user_id, instance_name),
)
return _row_to_cred(row)
def list_odoo_credentials(user_id: int) -> list[OdooCredential]:
rows = db_fetchall("SELECT * FROM odoo_credentials WHERE user_id=? ORDER BY instance_name", (user_id,))
return [_row_to_cred(r) for r in rows]
def delete_odoo_credential(user_id: int, instance_name: str) -> bool:
cur = db_execute(
"DELETE FROM odoo_credentials WHERE user_id=? AND instance_name=?",
(user_id, instance_name),
)
db_commit()
return cur.rowcount > 0
def get_odoo_instance_configs(user_id: int) -> list[OdooInstanceConfig]:
"""Return decrypted Odoo configs for this user, ready for ConnectionManager."""
creds = list_odoo_credentials(user_id)
return [
OdooInstanceConfig(
name=c.instance_name,
url=c.url,
db=c.database_name,
username=c.odoo_username,
credential=decrypt_credential(c.credential_enc),
)
for c in creds
]
def _row_to_cred(row) -> OdooCredential:
return OdooCredential(
id=row["id"],
user_id=row["user_id"],
instance_name=row["instance_name"],
url=row["url"],
database_name=row["database_name"],
odoo_username=row["odoo_username"],
credential_enc=row["credential_enc"],
is_api_key=bool(row["is_api_key"]),
created_at=row["created_at"],
)

View File

View File

@ -0,0 +1,82 @@
"""Per-user (non-singleton) connection manager."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from .rpc import OdooRPC
@dataclass
class OdooSession:
name: str
url: str
db: str
username: str
uid: int
version: str
rpc: OdooRPC
connected: bool = True
class ConnectionManager:
"""One instance per user. Holds all Odoo sessions for that user."""
def __init__(self, configs: list):
self._sessions: dict[str, OdooSession] = {}
self._configs: dict[str, object] = {cfg.name: cfg for cfg in configs}
def reload_configs(self, configs: list):
"""Replace credential list (e.g. after the user adds a new instance)."""
self._configs = {cfg.name: cfg for cfg in configs}
def connect(self, cfg) -> OdooSession:
uid, version = OdooRPC.authenticate(cfg.url, cfg.db, cfg.username, cfg.credential)
rpc = OdooRPC(cfg.url, cfg.db, uid, cfg.credential)
session = OdooSession(
name=cfg.name, url=cfg.url, db=cfg.db,
username=cfg.username, uid=uid, version=version, rpc=rpc,
)
self._sessions[cfg.name] = session
return session
def connect_all(self):
for cfg in self._configs.values():
try:
self.connect(cfg)
except Exception:
pass
def ensure_connected(self, instance: str = "default") -> OdooSession:
if instance not in self._sessions or not self._sessions[instance].connected:
cfg = self._configs.get(instance)
if not cfg:
available = list(self._configs.keys())
raise ValueError(
f"Odoo instance '{instance}' not configured. "
f"Available: {available}. "
f"Add it via POST /api/credentials."
)
self.connect(cfg)
return self._sessions[instance]
def get(self, instance: str = "default") -> OdooSession:
return self.ensure_connected(instance)
def rpc(self, instance: str = "default") -> OdooRPC:
return self.get(instance).rpc
def list_instances(self) -> list[dict]:
result = []
for name, cfg in self._configs.items():
session = self._sessions.get(name)
result.append({
"name": name,
"url": cfg.url,
"db": cfg.db,
"username": cfg.username,
"connected": session is not None and session.connected,
"version": session.version if session else None,
})
return result
def disconnect(self, instance: str):
self._sessions.pop(instance, None)

View File

@ -0,0 +1,77 @@
from __future__ import annotations
import threading
import xmlrpc.client
from typing import Any
class OdooRPC:
"""Thread-safe XML-RPC wrapper for Odoo's /xmlrpc/2/object endpoint."""
def __init__(self, url: str, db: str, uid: int, credential: str):
self._url = url.rstrip("/")
self._db = db
self._uid = uid
self._credential = credential
self._lock = threading.Lock()
self._proxy = xmlrpc.client.ServerProxy(f"{self._url}/xmlrpc/2/object")
def _call(self, model: str, method: str, args: list, kwargs: dict | None = None) -> Any:
with self._lock:
return self._proxy.execute_kw(
self._db, self._uid, self._credential,
model, method, args, kwargs or {}
)
def search(self, model: str, domain: list, *, limit: int = 100, offset: int = 0, order: str = "") -> list[int]:
kw: dict = {"limit": limit, "offset": offset}
if order:
kw["order"] = order
return self._call(model, "search", [domain], kw)
def search_read(self, model: str, domain: list, fields: list[str], *,
limit: int = 100, offset: int = 0, order: str = "") -> list[dict]:
kw: dict = {"fields": fields, "limit": limit, "offset": offset}
if order:
kw["order"] = order
return self._call(model, "search_read", [domain], kw)
def read(self, model: str, ids: list[int], fields: list[str]) -> list[dict]:
return self._call(model, "read", [ids], {"fields": fields})
def count(self, model: str, domain: list) -> int:
return self._call(model, "search_count", [domain], {})
def create(self, model: str, values: dict) -> int:
return self._call(model, "create", [values], {})
def write(self, model: str, ids: list[int], values: dict) -> bool:
return self._call(model, "write", [ids, values], {})
def unlink(self, model: str, ids: list[int]) -> bool:
return self._call(model, "unlink", [ids], {})
def fields_get(self, model: str, attrs: list[str] | None = None) -> dict:
kw: dict = {}
if attrs:
kw["attributes"] = attrs
return self._call(model, "fields_get", [], kw)
def execute(self, model: str, method: str, *args, **kwargs) -> Any:
return self._call(model, method, list(args), kwargs)
def model_exists(self, model: str) -> bool:
try:
self._call("ir.model", "search_count", [[["model", "=", model]]], {})
return True
except Exception:
return False
@staticmethod
def authenticate(url: str, db: str, username: str, credential: str) -> tuple[int, str]:
"""Returns (uid, server_version). Raises on failure."""
common = xmlrpc.client.ServerProxy(f"{url.rstrip('/')}/xmlrpc/2/common")
uid = common.authenticate(db, username, credential, {})
if not uid:
raise ValueError(f"Authentication failed for user '{username}' on '{url}' db='{db}'")
info = common.version()
return uid, info.get("server_version", "unknown")

29
src/mt_odoo_mcp/config.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Server
host: str = "0.0.0.0"
port: int = 8000
mcp_server_name: str = "mt-odoo-mcp"
mcp_log_level: str = "INFO"
# Security
secret_key: str = "change-me"
encryption_key: str = "change-me"
# Database
database_path: str = "./mt_odoo_mcp.db"
# Delete confirmation token TTL (seconds)
confirm_token_ttl: int = 300
# Audit
audit_log_file: str = "./audit.jsonl"
audit_log_enabled: bool = True
settings = Settings()

View File

View File

@ -0,0 +1,73 @@
"""Per-user (non-singleton) delete confirmation token store."""
from __future__ import annotations
import secrets
import threading
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Optional
from ..config import settings
@dataclass
class PendingDelete:
token: str
instance: str
model: str
record_id: int
record_name: str
expires_at: datetime
class ConfirmationManager:
"""In-memory store for delete confirmation tokens. Single-use, TTL-limited.
One instance per user no shared state between tenants.
"""
def __init__(self):
self._lock = threading.Lock()
self._pending: dict[str, PendingDelete] = {}
def _purge_expired(self):
now = datetime.now(timezone.utc)
expired = [t for t, p in self._pending.items() if p.expires_at <= now]
for t in expired:
del self._pending[t]
def create_token(self, instance: str, model: str, record_id: int, record_name: str) -> PendingDelete:
token = f"del_{secrets.token_hex(8)}"
ttl = settings.confirm_token_ttl
pending = PendingDelete(
token=token, instance=instance, model=model,
record_id=record_id, record_name=record_name,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl),
)
with self._lock:
self._purge_expired()
self._pending[token] = pending
return pending
def consume_token(self, token: str, model: str, record_id: int) -> tuple[bool, str]:
with self._lock:
self._purge_expired()
pending = self._pending.get(token)
if not pending:
return False, "Invalid or expired confirmation token. Call request_delete_confirmation again."
if pending.model != model or pending.record_id != record_id:
return False, "Token does not match the requested model/record."
del self._pending[token]
return True, ""
def list_pending(self) -> list[dict]:
with self._lock:
self._purge_expired()
now = datetime.now(timezone.utc)
return [
{
"token": p.token,
"model": p.model,
"record_id": p.record_id,
"record_name": p.record_name,
"expires_in_seconds": int((p.expires_at - now).total_seconds()),
}
for p in self._pending.values()
]

View File

@ -0,0 +1,45 @@
"""Per-request context vars — each MCP session carries its own user state."""
from __future__ import annotations
from contextvars import ContextVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .client.connection import ConnectionManager
from .confirmation.manager import ConfirmationManager
_user_id: ContextVar[int] = ContextVar("user_id")
_user_email: ContextVar[str] = ContextVar("user_email")
_conn_manager: ContextVar["ConnectionManager"] = ContextVar("conn_manager")
_confirm_manager: ContextVar["ConfirmationManager"] = ContextVar("confirm_manager")
def get_user_id() -> int:
return _user_id.get()
def get_user_email() -> str:
return _user_email.get()
def get_conn_manager() -> "ConnectionManager":
return _conn_manager.get()
def get_confirm_manager() -> "ConfirmationManager":
return _confirm_manager.get()
def set_user_context(user_id: int, user_email: str, conn_mgr, confirm_mgr):
"""Set all context vars for the current MCP session. Returns tokens for reset."""
t1 = _user_id.set(user_id)
t2 = _user_email.set(user_email)
t3 = _conn_manager.set(conn_mgr)
t4 = _confirm_manager.set(confirm_mgr)
return (t1, t2, t3, t4)
def reset_user_context(tokens):
_user_id.reset(tokens[0])
_user_email.reset(tokens[1])
_conn_manager.reset(tokens[2])
_confirm_manager.reset(tokens[3])

View File

@ -0,0 +1,71 @@
"""SQLite database — users and their Odoo credentials."""
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from .config import settings
_lock = threading.Lock()
_conn: sqlite3.Connection | None = None
def _get_conn() -> sqlite3.Connection:
global _conn
if _conn is None:
path = Path(settings.database_path)
path.parent.mkdir(parents=True, exist_ok=True)
_conn = sqlite3.connect(str(path), check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA foreign_keys=ON")
return _conn
def init_db():
with _lock:
db = _get_conn()
db.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
api_key_hash TEXT NOT NULL,
api_key_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS odoo_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
instance_name TEXT NOT NULL DEFAULT 'default',
url TEXT NOT NULL,
database_name TEXT NOT NULL,
odoo_username TEXT NOT NULL,
credential_enc TEXT NOT NULL,
is_api_key INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, instance_name)
);
""")
db.commit()
def db_execute(sql: str, params: tuple = ()) -> sqlite3.Cursor:
with _lock:
return _get_conn().execute(sql, params)
def db_commit():
with _lock:
_get_conn().commit()
def db_fetchone(sql: str, params: tuple = ()) -> sqlite3.Row | None:
with _lock:
return _get_conn().execute(sql, params).fetchone()
def db_fetchall(sql: str, params: tuple = ()) -> list[sqlite3.Row]:
with _lock:
return _get_conn().execute(sql, params).fetchall()

View File

@ -0,0 +1,43 @@
"""In-memory tenant registry — one ConnectionManager and ConfirmationManager per user."""
from __future__ import annotations
import threading
from .client.connection import ConnectionManager
from .confirmation.manager import ConfirmationManager
from .auth.service import get_odoo_instance_configs
_lock = threading.Lock()
_conn_managers: dict[int, ConnectionManager] = {}
_confirm_managers: dict[int, ConfirmationManager] = {}
def get_conn_manager(user_id: int) -> ConnectionManager:
"""Return the user's ConnectionManager, creating it if needed."""
with _lock:
if user_id not in _conn_managers:
configs = get_odoo_instance_configs(user_id)
_conn_managers[user_id] = ConnectionManager(configs)
return _conn_managers[user_id]
def get_confirm_manager(user_id: int) -> ConfirmationManager:
with _lock:
if user_id not in _confirm_managers:
_confirm_managers[user_id] = ConfirmationManager()
return _confirm_managers[user_id]
def refresh_conn_manager(user_id: int):
"""Force-reload credentials from DB (call after user adds/updates credentials)."""
with _lock:
configs = get_odoo_instance_configs(user_id)
if user_id in _conn_managers:
_conn_managers[user_id].reload_configs(configs)
else:
_conn_managers[user_id] = ConnectionManager(configs)
def evict_user(user_id: int):
"""Remove all in-memory state for a user (e.g. after account deletion)."""
with _lock:
_conn_managers.pop(user_id, None)
_confirm_managers.pop(user_id, None)

669
src/mt_odoo_mcp/server.py Normal file
View File

@ -0,0 +1,669 @@
from __future__ import annotations
import json
import logging
import sys
from typing import Any
import uvicorn
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.types import Tool, TextContent
from .config import settings
from .database import init_db
from .auth.service import get_user_by_api_key
from .registry import get_conn_manager, get_confirm_manager
from .context import set_user_context, reset_user_context
from .api.routes import router as api_router
# Tool implementations
from .tools import (
a_connection as conn_tools,
b_discovery as disc_tools,
c_records as rec_tools,
d_workflow as wf_tools,
e_smart as smart_tools,
)
logging.basicConfig(
level=getattr(logging, settings.mcp_log_level, logging.INFO),
stream=sys.stderr,
)
log = logging.getLogger(__name__)
# ─── MCP Server ───────────────────────────────────────────────────────────────
mcp_app = Server(settings.mcp_server_name)
TOOLS: list[tuple[str, str, Any, dict]] = [
# ── A. Connection ──────────────────────────────────────────────────────────
("list_instances", "List all your configured Odoo instances and their connection status.",
conn_tools.list_instances, {}),
("connect_instance", "Connect and authenticate to a named Odoo instance.",
conn_tools.connect_instance, {
"type": "object",
"properties": {
"instance": {"type": "string", "description": "Instance name (default: 'default')", "default": "default"},
},
}),
("get_current_context", "Get current session context: instance info, company, logged-in user, Odoo version.",
conn_tools.get_current_context, {
"type": "object",
"properties": {"instance": {"type": "string", "default": "default"}},
}),
("switch_instance", "Switch the active Odoo instance.",
conn_tools.switch_instance, {
"type": "object",
"properties": {"instance": {"type": "string", "description": "Name of the instance to switch to"}},
"required": ["instance"],
}),
("health_check", "Ping Odoo to verify the connection is alive.",
conn_tools.health_check, {
"type": "object",
"properties": {"instance": {"type": "string", "default": "default"}},
}),
# ── B. Discovery ───────────────────────────────────────────────────────────
("list_models", "List all data models available in Odoo.",
disc_tools.list_models, {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
}),
("search_models", "Search Odoo models by name or technical name keyword.",
disc_tools.search_models, {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["keyword"],
}),
("get_model_fields", "Get all fields of a model: name, type, label, required, readonly, and relations.",
disc_tools.get_model_fields, {
"type": "object",
"properties": {
"model": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model"],
}),
("get_selection_options", "Get all possible values for a Selection/dropdown field.",
disc_tools.get_selection_options, {
"type": "object",
"properties": {
"model": {"type": "string"},
"field": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "field"],
}),
("get_model_relations", "Get all relational fields (Many2one, One2many, Many2many) for a model.",
disc_tools.get_model_relations, {
"type": "object",
"properties": {
"model": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model"],
}),
("describe_record", "Get the full context of a single record: all field values with labels.",
disc_tools.describe_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
# ── C. Records CRUD ────────────────────────────────────────────────────────
("search_records", "Search records in any Odoo model.",
rec_tools.search_records, {
"type": "object",
"properties": {
"model": {"type": "string"},
"domain": {"type": "array", "default": []},
"fields": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "default": 80},
"offset": {"type": "integer", "default": 0},
"order": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model"],
}),
("read_record", "Read a single record by ID.",
rec_tools.read_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"fields": {"type": "array", "items": {"type": "string"}},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("read_records", "Read multiple records by their IDs.",
rec_tools.read_records, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_ids": {"type": "array", "items": {"type": "integer"}},
"fields": {"type": "array", "items": {"type": "string"}},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_ids"],
}),
("count_records", "Count records matching a domain.",
rec_tools.count_records, {
"type": "object",
"properties": {
"model": {"type": "string"},
"domain": {"type": "array", "default": []},
"instance": {"type": "string", "default": "default"},
},
"required": ["model"],
}),
("create_record", "Create a new record in any Odoo model.",
rec_tools.create_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"values": {"type": "object"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "values"],
}),
("update_record", "Update fields on an existing record.",
rec_tools.update_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"values": {"type": "object"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id", "values"],
}),
("request_delete_confirmation", "STEP 1 OF DELETE: Get a confirmation token before deleting a record.",
rec_tools.request_delete_confirmation, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("delete_record", "STEP 2 OF DELETE: Permanently delete a record. Requires confirmation_token.",
rec_tools.delete_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"confirmation_token": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("archive_record", "Soft-delete a record (sets active=False).",
rec_tools.archive_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("unarchive_record", "Restore an archived record.",
rec_tools.unarchive_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("duplicate_record", "Copy a record, optionally overriding some fields.",
rec_tools.duplicate_record, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"override_values": {"type": "object"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("get_related_records", "Follow a relation field to fetch linked records.",
rec_tools.get_related_records, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"relation_field": {"type": "string"},
"fields": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "default": 80},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id", "relation_field"],
}),
("export_records", "Export records as JSON for review or reporting.",
rec_tools.export_records, {
"type": "object",
"properties": {
"model": {"type": "string"},
"domain": {"type": "array", "default": []},
"fields": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "default": 200},
"instance": {"type": "string", "default": "default"},
},
"required": ["model"],
}),
# ── D. Workflow ────────────────────────────────────────────────────────────
("list_record_actions", "List available server actions/buttons for a record.",
wf_tools.list_record_actions, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("execute_record_action", "Execute a method/button action on a record.",
wf_tools.execute_record_action, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"method": {"type": "string"},
"args": {"type": "array"},
"kwargs": {"type": "object"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id", "method"],
}),
("confirm_document", "Confirm/validate a document (sale order, purchase order, invoice, picking).",
wf_tools.confirm_document, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("cancel_document", "Cancel a document.",
wf_tools.cancel_document, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("reset_to_draft", "Reset a document back to draft state.",
wf_tools.reset_to_draft, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
("change_record_stage", "Move a record to a specific stage by stage ID.",
wf_tools.change_record_stage, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"stage_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id", "stage_id"],
}),
("log_note", "Add an internal note to a record's chatter.",
wf_tools.log_note, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"note": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id", "note"],
}),
("get_record_chatter", "Get message history and internal notes from a record's chatter.",
wf_tools.get_record_chatter, {
"type": "object",
"properties": {
"model": {"type": "string"},
"record_id": {"type": "integer"},
"limit": {"type": "integer", "default": 20},
"instance": {"type": "string", "default": "default"},
},
"required": ["model", "record_id"],
}),
# ── E. Smart Business Tools ────────────────────────────────────────────────
("list_employees", "List employees with filters by department, job title, or name.",
smart_tools.list_employees, {
"type": "object",
"properties": {
"department": {"type": "string"},
"job_title": {"type": "string"},
"name": {"type": "string"},
"active_only": {"type": "boolean", "default": True},
"instance": {"type": "string", "default": "default"},
},
}),
("get_employee_details", "Get full profile of an employee.",
smart_tools.get_employee_details, {
"type": "object",
"properties": {
"employee_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["employee_id"],
}),
("get_employee_workload", "Get tasks and projects currently assigned to an employee.",
smart_tools.get_employee_workload, {
"type": "object",
"properties": {
"employee_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["employee_id"],
}),
("list_departments", "List all departments with head of department.",
smart_tools.list_departments, {
"type": "object",
"properties": {"instance": {"type": "string", "default": "default"}},
}),
("list_job_positions", "List available job positions.",
smart_tools.list_job_positions, {
"type": "object",
"properties": {
"department": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
}),
("list_projects", "List projects with optional filters.",
smart_tools.list_projects, {
"type": "object",
"properties": {
"name": {"type": "string"},
"customer": {"type": "string"},
"manager": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
}),
("get_project_details", "Get full project details: tasks grouped by stage, team members, dates.",
smart_tools.get_project_details, {
"type": "object",
"properties": {
"project_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["project_id"],
}),
("create_project", "Create a new project in Odoo.",
smart_tools.create_project, {
"type": "object",
"properties": {
"name": {"type": "string"},
"customer_id": {"type": "integer"},
"manager_user_id": {"type": "integer"},
"start_date": {"type": "string"},
"end_date": {"type": "string"},
"description": {"type": "string"},
"instance": {"type": "string", "default": "default"},
},
"required": ["name"],
}),
("list_tasks", "List project tasks with filters.",
smart_tools.list_tasks, {
"type": "object",
"properties": {
"project_id": {"type": "integer"},
"assignee_user_id": {"type": "integer"},
"stage": {"type": "string"},
"name": {"type": "string"},
"deadline_before": {"type": "string"},
"include_done": {"type": "boolean", "default": False},
"instance": {"type": "string", "default": "default"},
},
}),
("create_task", "Create a task in a project.",
smart_tools.create_task, {
"type": "object",
"properties": {
"name": {"type": "string"},
"project_id": {"type": "integer"},
"assignee_user_ids": {"type": "array", "items": {"type": "integer"}},
"description": {"type": "string"},
"deadline": {"type": "string"},
"priority": {"type": "string", "enum": ["normal", "high"], "default": "normal"},
"stage_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["name", "project_id"],
}),
("assign_task", "Assign or reassign a task to one or more employees.",
smart_tools.assign_task, {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"user_ids": {"type": "array", "items": {"type": "integer"}},
"replace": {"type": "boolean", "default": True},
"instance": {"type": "string", "default": "default"},
},
"required": ["task_id", "user_ids"],
}),
("update_task_stage", "Move a task to a different stage.",
smart_tools.update_task_stage, {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"stage_id": {"type": "integer"},
"instance": {"type": "string", "default": "default"},
},
"required": ["task_id", "stage_id"],
}),
("list_customers", "List customers/partners with filters.",
smart_tools.list_customers, {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"is_company": {"type": "boolean"},
"limit": {"type": "integer", "default": 50},
"instance": {"type": "string", "default": "default"},
},
}),
("list_sales_orders", "List sales orders with status and customer filters.",
smart_tools.list_sales_orders, {
"type": "object",
"properties": {
"customer_id": {"type": "integer"},
"status": {"type": "string", "enum": ["draft", "sent", "sale", "done", "cancel"]},
"limit": {"type": "integer", "default": 50},
"instance": {"type": "string", "default": "default"},
},
}),
("list_invoices", "List invoices with customer, status, and type filters.",
smart_tools.list_invoices, {
"type": "object",
"properties": {
"customer_id": {"type": "integer"},
"status": {"type": "string", "enum": ["draft", "posted", "cancel"]},
"invoice_type": {"type": "string", "enum": ["out_invoice", "in_invoice", "out_refund", "in_refund"],
"default": "out_invoice"},
"limit": {"type": "integer", "default": 50},
"instance": {"type": "string", "default": "default"},
},
}),
]
@mcp_app.list_tools()
async def list_tools() -> list[Tool]:
return [Tool(name=name, description=desc, inputSchema=schema) for name, desc, _, schema in TOOLS]
@mcp_app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
entry = next((t for t in TOOLS if t[0] == name), None)
if not entry:
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: '{name}'"}))]
_, _, fn, _ = entry
instance = arguments.get("instance", "default")
# Auto-connect instance (skip for tools that explicitly handle connection errors)
from .context import get_conn_manager as _gcm
try:
_gcm().ensure_connected(instance)
except Exception as e:
if name not in ("list_instances", "health_check", "connect_instance"):
return [TextContent(type="text", text=json.dumps(
{"error": f"Cannot connect to Odoo instance '{instance}': {e}. "
f"Add your credentials via POST /api/credentials."}))]
try:
result = fn(**arguments)
except TypeError as e:
return [TextContent(type="text", text=json.dumps({"error": f"Invalid arguments: {e}"}))]
except Exception as e:
log.exception("Tool '%s' raised an error", name)
return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))]
# ─── FastAPI App ──────────────────────────────────────────────────────────────
app = FastAPI(
title="Multi-Tenant Odoo MCP",
description="Self-hosted MCP server for Odoo. Users register and manage their own Odoo instances.",
version="1.0.0",
)
# Mount the REST API routes
app.include_router(api_router)
# SSE transport — clients connect here
sse_transport = SseServerTransport("/mcp/messages/")
@app.get("/health")
async def health():
return {"status": "ok", "server": settings.mcp_server_name}
@app.get("/mcp/sse")
async def mcp_sse_endpoint(request: Request):
"""MCP SSE connection endpoint. Authenticate with: ?api_key=<your_key> or Authorization header."""
# Extract API key from query param or Authorization header
api_key = request.query_params.get("api_key")
if not api_key:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
api_key = auth_header.removeprefix("Bearer ").strip()
if not api_key:
raise HTTPException(
status_code=401,
detail="API key required. Pass ?api_key=<key> or Authorization: Bearer <key>",
)
user = get_user_by_api_key(api_key)
if not user:
raise HTTPException(status_code=401, detail="Invalid API key.")
log.info("MCP SSE connection: user=%s (id=%d)", user.email, user.id)
# Build per-user managers
conn_mgr = get_conn_manager(user.id)
confirm_mgr = get_confirm_manager(user.id)
# Set context vars for this async task (propagates to all child tasks)
tokens = set_user_context(user.id, user.email, conn_mgr, confirm_mgr)
try:
async with sse_transport.connect_sse(
request.scope, request.receive, request._send
) as (read_stream, write_stream):
await mcp_app.run(read_stream, write_stream, mcp_app.create_initialization_options())
finally:
reset_user_context(tokens)
@app.post("/mcp/messages/")
async def mcp_messages_endpoint(request: Request):
"""MCP message channel (used internally by the SSE transport)."""
return await sse_transport.handle_post_message(request.scope, request.receive, request._send)
# ─── Entry Point ──────────────────────────────────────────────────────────────
def main():
init_db()
log.info("Starting %s on %s:%d", settings.mcp_server_name, settings.host, settings.port)
uvicorn.run(app, host=settings.host, port=settings.port, log_level=settings.mcp_log_level.lower())
if __name__ == "__main__":
main()

View File

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from ..context import get_conn_manager, get_user_id, get_user_email
def list_instances() -> dict:
instances = get_conn_manager().list_instances()
return {
"instances": instances,
"total": len(instances),
"default_instance": instances[0]["name"] if instances else None,
}
def connect_instance(instance: str = "default") -> dict:
try:
session = get_conn_manager().ensure_connected(instance)
return {
"success": True,
"instance": session.name,
"url": session.url,
"database": session.db,
"username": session.username,
"uid": session.uid,
"odoo_version": session.version,
}
except Exception as e:
return {"success": False, "error": str(e)}
def get_current_context(instance: str = "default") -> dict:
try:
mgr = get_conn_manager()
session = mgr.ensure_connected(instance)
rpc = session.rpc
user = rpc.search_read(
"res.users",
[["id", "=", session.uid]],
["name", "login", "company_id"],
limit=1,
)
user_data = user[0] if user else {}
company_id = user_data.get("company_id", [None, ""])[0] if user_data.get("company_id") else None
company_name = user_data.get("company_id", [None, ""])[1] if user_data.get("company_id") else None
return {
"mcp_user_email": get_user_email(),
"instance": instance,
"url": session.url,
"database": session.db,
"odoo_version": session.version,
"odoo_user": {
"id": session.uid,
"name": user_data.get("name"),
"login": user_data.get("login"),
},
"company": {"id": company_id, "name": company_name},
}
except Exception as e:
return {"error": str(e)}
def switch_instance(instance: str) -> dict:
return connect_instance(instance)
def health_check(instance: str = "default") -> dict:
try:
session = get_conn_manager().ensure_connected(instance)
count = session.rpc.count("res.users", [["active", "=", True]])
return {
"healthy": True,
"instance": instance,
"url": session.url,
"database": session.db,
"odoo_version": session.version,
"active_users": count,
}
except Exception as e:
return {"healthy": False, "instance": instance, "error": str(e)}

View File

@ -0,0 +1,121 @@
from __future__ import annotations
from ..context import get_conn_manager
def list_models(keyword: str = "", instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = []
if keyword:
domain = ["|", ["model", "ilike", keyword], ["name", "ilike", keyword]]
models = rpc.search_read(
"ir.model", domain, ["model", "name", "transient"],
limit=200, order="model asc",
)
return {
"models": [{"model": m["model"], "label": m["name"], "transient": m["transient"]} for m in models],
"total": len(models),
"keyword": keyword or None,
}
def search_models(keyword: str, instance: str = "default") -> dict:
return list_models(keyword=keyword, instance=instance)
def get_model_fields(model: str, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
raw = rpc.fields_get(model, ["string", "type", "required", "readonly", "relation",
"selection", "help", "store", "compute"])
except Exception as e:
return {"error": f"Cannot get fields for '{model}': {e}"}
fields = []
for fname, fdata in raw.items():
entry: dict = {
"name": fname,
"label": fdata.get("string", fname),
"type": fdata.get("type"),
"required": fdata.get("required", False),
"readonly": fdata.get("readonly", False),
"stored": fdata.get("store", True),
"computed": bool(fdata.get("compute")),
}
if fdata.get("relation"):
entry["relation"] = fdata["relation"]
if fdata.get("selection"):
entry["selection"] = fdata["selection"]
if fdata.get("help"):
entry["help"] = fdata["help"]
fields.append(entry)
fields.sort(key=lambda f: f["name"])
return {"model": model, "fields": fields, "total": len(fields)}
def get_selection_options(model: str, field: str, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
raw = rpc.fields_get(model, ["string", "type", "selection"])
except Exception as e:
return {"error": str(e)}
fdata = raw.get(field)
if not fdata:
return {"error": f"Field '{field}' not found on model '{model}'"}
if fdata.get("type") != "selection":
return {"error": f"Field '{field}' is type '{fdata.get('type')}', not 'selection'"}
options = [{"value": v, "label": l} for v, l in (fdata.get("selection") or [])]
return {"model": model, "field": field, "options": options}
def get_model_relations(model: str, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
raw = rpc.fields_get(model, ["string", "type", "relation", "relation_field"])
except Exception as e:
return {"error": str(e)}
relation_types = {"many2one", "one2many", "many2many"}
relations = [
{
"field": fname,
"label": fdata.get("string", fname),
"type": fdata["type"],
"related_model": fdata.get("relation"),
"inverse_field": fdata.get("relation_field"),
}
for fname, fdata in raw.items()
if fdata.get("type") in relation_types and fdata.get("relation")
]
relations.sort(key=lambda r: r["field"])
return {"model": model, "relations": relations, "total": len(relations)}
def describe_record(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
raw_fields = rpc.fields_get(model, ["string", "type", "relation", "store"])
except Exception as e:
return {"error": str(e)}
stored_fields = [
fname for fname, fd in raw_fields.items()
if fd.get("store", True) and fd.get("type") not in ("one2many", "many2many")
]
records = rpc.read(model, [record_id], stored_fields)
if not records:
return {"error": f"Record {record_id} not found in '{model}'"}
record = records[0]
readable: dict = {}
for fname, value in record.items():
ftype = raw_fields.get(fname, {}).get("type", "")
label = raw_fields.get(fname, {}).get("string", fname)
if ftype == "many2one" and isinstance(value, list):
readable[fname] = {"label": label, "id": value[0], "name": value[1]}
else:
readable[fname] = {"label": label, "value": value}
return {"model": model, "record_id": record_id, "fields": readable}

View File

@ -0,0 +1,259 @@
from __future__ import annotations
from typing import Optional
from ..context import get_conn_manager, get_confirm_manager, get_user_id, get_user_email
from ..audit.logger import audit_logger
def _name_of(rpc, model: str, record_id: int) -> str:
try:
rows = rpc.read(model, [record_id], ["name", "display_name"])
if rows:
return rows[0].get("display_name") or rows[0].get("name") or f"#{record_id}"
except Exception:
pass
return f"#{record_id}"
def _audit(tool, params, summary, instance, success=True, error=None):
audit_logger.log(tool, params, summary, instance,
user_id=get_user_id(), user_email=get_user_email(),
success=success, error=error)
def search_records(
model: str, domain: Optional[list] = None, fields: Optional[list[str]] = None,
limit: int = 80, offset: int = 0, order: str = "", instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain = domain or []
fields = fields or ["id", "display_name"]
limit = min(limit, 500)
try:
records = rpc.search_read(model, domain, fields, limit=limit, offset=offset, order=order)
total = rpc.count(model, domain)
except Exception as e:
return {"error": str(e)}
_audit("search_records", {"model": model, "domain": domain, "limit": limit}, f"{len(records)} records", instance)
return {
"model": model, "records": records, "count": len(records),
"total_matching": total, "offset": offset,
"has_more": (offset + len(records)) < total,
}
def read_record(model: str, record_id: int, fields: Optional[list[str]] = None, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
fields = fields or []
try:
rows = rpc.read(model, [record_id], fields)
except Exception as e:
return {"error": str(e)}
if not rows:
return {"error": f"Record {record_id} not found in '{model}'"}
_audit("read_record", {"model": model, "record_id": record_id}, "read ok", instance)
return {"model": model, "record": rows[0]}
def read_records(model: str, record_ids: list[int], fields: Optional[list[str]] = None, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
fields = fields or []
try:
rows = rpc.read(model, record_ids, fields)
except Exception as e:
return {"error": str(e)}
_audit("read_records", {"model": model, "record_ids": record_ids}, f"{len(rows)} records", instance)
return {"model": model, "records": rows, "count": len(rows)}
def count_records(model: str, domain: Optional[list] = None, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
domain = domain or []
try:
total = rpc.count(model, domain)
except Exception as e:
return {"error": str(e)}
return {"model": model, "domain": domain, "count": total}
def create_record(model: str, values: dict, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
new_id = rpc.create(model, values)
except Exception as e:
_audit("create_record", {"model": model, "values": values}, "", instance, success=False, error=str(e))
return {"error": str(e)}
name = _name_of(rpc, model, new_id)
_audit("create_record", {"model": model, "values": values}, f"Created '{name}' (id={new_id})", instance)
return {"success": True, "model": model, "record_id": new_id, "record_name": name,
"message": f"Created '{name}' with id={new_id}"}
def update_record(model: str, record_id: int, values: dict, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
name = _name_of(rpc, model, record_id)
try:
rpc.write(model, [record_id], values)
except Exception as e:
_audit("update_record", {"model": model, "record_id": record_id, "values": values}, "", instance, success=False, error=str(e))
return {"error": str(e)}
_audit("update_record", {"model": model, "record_id": record_id, "values": values}, f"Updated '{name}'", instance)
return {"success": True, "model": model, "record_id": record_id, "record_name": name,
"updated_fields": list(values.keys()), "message": f"Updated '{name}' (id={record_id})"}
def request_delete_confirmation(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
record_name = _name_of(rpc, model, record_id)
try:
rows = rpc.read(model, [record_id], [])
if not rows:
return {"error": f"Record {record_id} not found in '{model}'"}
except Exception as e:
return {"error": f"Cannot read record: {e}"}
pending = get_confirm_manager().create_token(instance, model, record_id, record_name)
import time
ttl = int(pending.expires_at.timestamp() - time.time())
return {
"action_required": "CONFIRM_WITH_USER",
"warning": "⚠️ This will permanently delete the record. This cannot be undone.",
"record_to_delete": {"model": model, "record_id": record_id, "record_name": record_name},
"confirmation_token": pending.token,
"token_valid_for_seconds": ttl,
"instructions": (
f"Present this to the user: 'You are about to permanently delete "
f"**{record_name}** (id={record_id}) from {model}. Are you sure?' "
f"Only call delete_record with this token if the user explicitly confirms."
),
}
def delete_record(
model: str, record_id: int,
confirmation_token: Optional[str] = None,
instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
if not confirmation_token:
record_name = _name_of(rpc, model, record_id)
return {
"error": "delete_requires_confirmation",
"message": (
"Delete requires explicit user confirmation. "
"Call request_delete_confirmation first, present the details to the user, "
"then call delete_record with the confirmation_token."
),
"record": {"model": model, "record_id": record_id, "record_name": record_name},
"next_step": "call request_delete_confirmation(model, record_id)",
}
ok, err = get_confirm_manager().consume_token(confirmation_token, model, record_id)
if not ok:
return {"error": err}
record_name = _name_of(rpc, model, record_id)
try:
rpc.unlink(model, [record_id])
except Exception as e:
_audit("delete_record", {"model": model, "record_id": record_id}, "", instance, success=False, error=str(e))
return {"error": f"Delete failed: {e}"}
_audit("delete_record", {"model": model, "record_id": record_id, "record_name": record_name},
f"Deleted '{record_name}'", instance)
return {"success": True, "model": model, "record_id": record_id, "record_name": record_name,
"message": f"Permanently deleted '{record_name}' (id={record_id}) from {model}."}
def archive_record(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
name = _name_of(rpc, model, record_id)
try:
rpc.write(model, [record_id], {"active": False})
except Exception as e:
return {"error": str(e)}
_audit("archive_record", {"model": model, "record_id": record_id}, f"Archived '{name}'", instance)
return {"success": True, "model": model, "record_id": record_id, "record_name": name,
"message": f"Archived '{name}'. Record is hidden but can be restored with unarchive_record."}
def unarchive_record(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
name = _name_of(rpc, model, record_id)
try:
rpc.write(model, [record_id], {"active": True})
except Exception as e:
return {"error": str(e)}
_audit("unarchive_record", {"model": model, "record_id": record_id}, f"Unarchived '{name}'", instance)
return {"success": True, "model": model, "record_id": record_id, "record_name": name,
"message": f"Restored '{name}' — record is now active."}
def duplicate_record(model: str, record_id: int, override_values: Optional[dict] = None, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
name = _name_of(rpc, model, record_id)
try:
new_id = rpc.execute(model, "copy", record_id, override_values or {})
except Exception as e:
return {"error": str(e)}
new_name = _name_of(rpc, model, new_id)
_audit("duplicate_record", {"model": model, "record_id": record_id}, f"Duplicated as id={new_id}", instance)
return {"success": True, "original": {"id": record_id, "name": name},
"copy": {"id": new_id, "name": new_name},
"message": f"Duplicated '{name}' → new record '{new_name}' (id={new_id})"}
def get_related_records(
model: str, record_id: int, relation_field: str,
fields: Optional[list[str]] = None, limit: int = 80, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
fields = fields or ["id", "display_name"]
try:
parent = rpc.read(model, [record_id], [relation_field])
except Exception as e:
return {"error": str(e)}
if not parent:
return {"error": f"Record {record_id} not found in '{model}'"}
value = parent[0].get(relation_field)
if value is None:
return {"error": f"Field '{relation_field}' not found on '{model}'"}
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], str):
rel_id = value[0]
rel_fields_raw = rpc.fields_get(model, ["relation"])
related_model = rel_fields_raw.get(relation_field, {}).get("relation", "unknown")
rows = rpc.read(related_model, [rel_id], fields)
return {"relation_type": "many2one", "parent_model": model, "parent_id": record_id,
"related_model": related_model, "records": rows}
if isinstance(value, list):
rel_fields_raw = rpc.fields_get(model, ["relation"])
related_model = rel_fields_raw.get(relation_field, {}).get("relation", "unknown")
ids = value[:limit]
rows = rpc.read(related_model, ids, fields) if ids else []
return {"relation_type": "one2many_or_many2many", "parent_model": model, "parent_id": record_id,
"related_model": related_model, "records": rows,
"total_ids": len(value), "returned": len(rows)}
return {"error": f"Unexpected value type for field '{relation_field}': {type(value).__name__}"}
def export_records(
model: str, domain: Optional[list] = None,
fields: Optional[list[str]] = None, limit: int = 200, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain = domain or []
fields = fields or ["id", "display_name"]
limit = min(limit, 1000)
try:
records = rpc.search_read(model, domain, fields, limit=limit)
total = rpc.count(model, domain)
except Exception as e:
return {"error": str(e)}
_audit("export_records", {"model": model, "domain": domain, "limit": limit},
f"Exported {len(records)} records", instance)
return {"model": model, "exported": len(records), "total_matching": total,
"fields": fields, "records": records}

View File

@ -0,0 +1,154 @@
from __future__ import annotations
import re
from typing import Optional
from ..context import get_conn_manager, get_user_id, get_user_email
from ..audit.logger import audit_logger
def _audit(tool, params, summary, instance, success=True, error=None):
audit_logger.log(tool, params, summary, instance,
user_id=get_user_id(), user_email=get_user_email(),
success=success, error=error)
def list_record_actions(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
actions = rpc.search_read(
"ir.actions.server",
[["model_name", "=", model], ["binding_type", "in", ["action", "report"]]],
["name", "binding_type", "state", "groups_id"],
limit=50,
)
except Exception as e:
return {"error": str(e)}
return {
"model": model, "record_id": record_id,
"available_actions": [{"name": a["name"], "type": a["binding_type"], "state": a["state"]} for a in actions],
"total": len(actions),
}
def execute_record_action(
model: str, record_id: int, method: str,
args: Optional[list] = None, kwargs: Optional[dict] = None,
instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
try:
result = rpc.execute(model, method, [record_id], *(args or []), **(kwargs or {}))
except Exception as e:
_audit("execute_record_action", {"model": model, "record_id": record_id, "method": method}, "", instance, success=False, error=str(e))
return {"error": str(e)}
_audit("execute_record_action", {"model": model, "record_id": record_id, "method": method}, f"Executed '{method}'", instance)
return {
"success": True, "model": model, "record_id": record_id, "method": method,
"result": result if not isinstance(result, dict) or len(str(result)) < 2000 else "Action returned a wizard/view (success)",
}
def confirm_document(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
confirm_methods = {
"sale.order": "action_confirm",
"purchase.order": "button_confirm",
"account.move": "action_post",
"stock.picking": "button_validate",
"mrp.production": "button_mark_done",
}
method = confirm_methods.get(model, "action_confirm")
try:
rpc.execute(model, method, [record_id])
except Exception as e:
_audit("confirm_document", {"model": model, "record_id": record_id, "method": method}, "", instance, success=False, error=str(e))
return {"error": str(e), "tried_method": method}
_audit("confirm_document", {"model": model, "record_id": record_id, "method": method}, f"Confirmed via {method}", instance)
return {"success": True, "model": model, "record_id": record_id, "method_used": method,
"message": "Document confirmed/validated successfully."}
def cancel_document(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
cancel_methods = {
"sale.order": "action_cancel",
"purchase.order": "button_cancel",
"account.move": "button_cancel",
"stock.picking": "action_cancel",
}
method = cancel_methods.get(model, "action_cancel")
try:
rpc.execute(model, method, [record_id])
except Exception as e:
_audit("cancel_document", {"model": model, "record_id": record_id}, "", instance, success=False, error=str(e))
return {"error": str(e), "tried_method": method}
_audit("cancel_document", {"model": model, "record_id": record_id}, f"Cancelled via {method}", instance)
return {"success": True, "model": model, "record_id": record_id, "method_used": method,
"message": "Document cancelled successfully."}
def reset_to_draft(model: str, record_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
draft_methods = {
"sale.order": "action_draft",
"account.move": "button_draft",
"stock.picking": "action_draft",
}
method = draft_methods.get(model, "action_draft")
try:
rpc.execute(model, method, [record_id])
except Exception as e:
return {"error": str(e), "tried_method": method}
_audit("reset_to_draft", {"model": model, "record_id": record_id}, f"Reset to draft via {method}", instance)
return {"success": True, "model": model, "record_id": record_id, "message": "Document reset to draft."}
def change_record_stage(model: str, record_id: int, stage_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
field = "stage_id"
try:
rpc.write(model, [record_id], {field: stage_id})
except Exception as e:
return {"error": str(e)}
_audit("change_record_stage", {"model": model, "record_id": record_id, "stage_id": stage_id},
f"Stage changed to {stage_id}", instance)
return {"success": True, "model": model, "record_id": record_id,
"new_stage_id": stage_id, "message": f"Record moved to stage {stage_id}."}
def log_note(model: str, record_id: int, note: str, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
msg_id = rpc.execute(model, "message_post", record_id,
body=note, message_type="comment", subtype_xmlid="mail.mt_note")
except Exception as e:
return {"error": str(e)}
_audit("log_note", {"model": model, "record_id": record_id}, "Note added", instance)
return {"success": True, "model": model, "record_id": record_id,
"message_id": msg_id, "message": "Internal note added to record."}
def get_record_chatter(model: str, record_id: int, limit: int = 20, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
messages = rpc.search_read(
"mail.message",
[["res_id", "=", record_id], ["model", "=", model]],
["date", "author_id", "message_type", "subtype_id", "body", "subject"],
limit=limit, order="date desc",
)
except Exception as e:
return {"error": str(e)}
cleaned = []
for m in messages:
body = re.sub(r"<[^>]+>", "", m.get("body", "")).strip()
cleaned.append({
"date": m.get("date"),
"author": m.get("author_id", [None, "Unknown"])[1],
"type": m.get("message_type"),
"subtype": m.get("subtype_id", [None, ""])[1] if m.get("subtype_id") else None,
"subject": m.get("subject"),
"body": body[:500],
})
return {"model": model, "record_id": record_id, "messages": cleaned, "total_returned": len(cleaned)}

View File

@ -0,0 +1,487 @@
from __future__ import annotations
from typing import Optional
from ..context import get_conn_manager, get_user_id, get_user_email
from ..audit.logger import audit_logger
def _audit(tool, params, summary, instance, success=True, error=None):
audit_logger.log(tool, params, summary, instance,
user_id=get_user_id(), user_email=get_user_email(),
success=success, error=error)
# ─── Employees ────────────────────────────────────────────────────────────────
def list_employees(
department: Optional[str] = None, job_title: Optional[str] = None,
name: Optional[str] = None, active_only: bool = True, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = []
if active_only:
domain.append(["active", "=", True])
if department:
domain.append(["department_id.name", "ilike", department])
if job_title:
domain.append(["job_title", "ilike", job_title])
if name:
domain.append(["name", "ilike", name])
try:
employees = rpc.search_read(
"hr.employee", domain,
["id", "name", "job_title", "job_id", "department_id", "work_email", "user_id", "parent_id"],
limit=200, order="department_id asc, name asc",
)
except Exception as e:
return {"error": str(e)}
result = [
{
"id": emp["id"], "name": emp["name"],
"job_title": emp.get("job_title"),
"job_position": emp["job_id"][1] if emp.get("job_id") else None,
"department": emp["department_id"][1] if emp.get("department_id") else None,
"department_id": emp["department_id"][0] if emp.get("department_id") else None,
"email": emp.get("work_email"),
"odoo_user_id": emp["user_id"][0] if emp.get("user_id") else None,
"odoo_username": emp["user_id"][1] if emp.get("user_id") else None,
"manager": emp["parent_id"][1] if emp.get("parent_id") else None,
}
for emp in employees
]
return {"employees": result, "total": len(result),
"filters": {"department": department, "job_title": job_title, "name": name}}
def get_employee_details(employee_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
rows = rpc.read("hr.employee", [employee_id],
["name", "job_title", "job_id", "department_id", "parent_id",
"work_email", "work_phone", "mobile_phone", "user_id", "child_ids", "coach_id"])
except Exception as e:
return {"error": str(e)}
if not rows:
return {"error": f"Employee {employee_id} not found"}
emp = rows[0]
return {
"id": emp["id"], "name": emp["name"],
"job_title": emp.get("job_title"),
"job_position": emp["job_id"][1] if emp.get("job_id") else None,
"department": emp["department_id"][1] if emp.get("department_id") else None,
"manager": emp["parent_id"][1] if emp.get("parent_id") else None,
"coach": emp["coach_id"][1] if emp.get("coach_id") else None,
"work_email": emp.get("work_email"),
"work_phone": emp.get("work_phone"),
"mobile_phone": emp.get("mobile_phone"),
"odoo_user_id": emp["user_id"][0] if emp.get("user_id") else None,
"odoo_login": emp["user_id"][1] if emp.get("user_id") else None,
"subordinates_count": len(emp.get("child_ids", [])),
}
def get_employee_workload(employee_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
emp_rows = rpc.read("hr.employee", [employee_id], ["name", "user_id"])
except Exception as e:
return {"error": str(e)}
if not emp_rows:
return {"error": f"Employee {employee_id} not found"}
emp = emp_rows[0]
emp_name = emp["name"]
user_id = emp["user_id"][0] if emp.get("user_id") else None
if not user_id:
return {"employee": emp_name, "warning": "Employee has no linked Odoo user account.",
"tasks": [], "projects": []}
try:
tasks = rpc.search_read(
"project.task",
[["user_ids", "in", [user_id]], ["stage_id.fold", "!=", True]],
["id", "name", "project_id", "stage_id", "date_deadline", "priority"],
limit=50, order="date_deadline asc",
)
projects = rpc.search_read(
"project.project",
[["user_id", "=", user_id], ["active", "=", True]],
["id", "name", "partner_id", "date_start", "date"],
limit=20,
)
except Exception as e:
return {"error": str(e)}
return {
"employee": emp_name, "employee_id": employee_id, "odoo_user_id": user_id,
"assigned_tasks": [
{"id": t["id"], "name": t["name"],
"project": t["project_id"][1] if t.get("project_id") else None,
"stage": t["stage_id"][1] if t.get("stage_id") else None,
"deadline": t.get("date_deadline"), "priority": t.get("priority")}
for t in tasks
],
"managed_projects": [
{"id": p["id"], "name": p["name"],
"customer": p["partner_id"][1] if p.get("partner_id") else None,
"start_date": p.get("date_start"), "end_date": p.get("date")}
for p in projects
],
"open_task_count": len(tasks),
}
def list_departments(instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
depts = rpc.search_read("hr.department", [], ["id", "name", "manager_id", "parent_id"],
limit=100, order="name asc")
except Exception as e:
return {"error": str(e)}
return {
"departments": [
{"id": d["id"], "name": d["name"],
"manager": d["manager_id"][1] if d.get("manager_id") else None,
"manager_id": d["manager_id"][0] if d.get("manager_id") else None,
"parent_department": d["parent_id"][1] if d.get("parent_id") else None}
for d in depts
],
"total": len(depts),
}
def list_job_positions(department: Optional[str] = None, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = []
if department:
domain.append(["department_id.name", "ilike", department])
try:
positions = rpc.search_read("hr.job", domain,
["id", "name", "department_id", "no_of_employee", "no_of_recruitment"],
limit=100, order="name asc")
except Exception as e:
return {"error": str(e)}
return {
"job_positions": [
{"id": p["id"], "title": p["name"],
"department": p["department_id"][1] if p.get("department_id") else None,
"current_employees": p.get("no_of_employee", 0),
"open_recruitments": p.get("no_of_recruitment", 0)}
for p in positions
],
"total": len(positions),
}
# ─── Projects ─────────────────────────────────────────────────────────────────
def list_projects(
name: Optional[str] = None, customer: Optional[str] = None,
manager: Optional[str] = None, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = [["active", "=", True]]
if customer:
domain.append(["partner_id.name", "ilike", customer])
if manager:
domain.append(["user_id.name", "ilike", manager])
if name:
domain.append(["name", "ilike", name])
try:
projects = rpc.search_read(
"project.project", domain,
["id", "name", "partner_id", "user_id", "date_start", "date", "task_count"],
limit=100, order="name asc",
)
except Exception as e:
return {"error": str(e)}
return {
"projects": [
{"id": p["id"], "name": p["name"],
"customer": p["partner_id"][1] if p.get("partner_id") else None,
"customer_id": p["partner_id"][0] if p.get("partner_id") else None,
"manager": p["user_id"][1] if p.get("user_id") else None,
"manager_id": p["user_id"][0] if p.get("user_id") else None,
"start_date": p.get("date_start"), "end_date": p.get("date"),
"task_count": p.get("task_count", 0)}
for p in projects
],
"total": len(projects),
}
def get_project_details(project_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
rows = rpc.read("project.project", [project_id],
["name", "partner_id", "user_id", "date_start", "date", "description", "task_count"])
except Exception as e:
return {"error": str(e)}
if not rows:
return {"error": f"Project {project_id} not found"}
p = rows[0]
try:
tasks = rpc.search_read(
"project.task", [["project_id", "=", project_id]],
["id", "name", "stage_id", "user_ids", "date_deadline", "priority"],
limit=200, order="stage_id asc, name asc",
)
except Exception:
tasks = []
try:
stages = rpc.search_read(
"project.task.type", [["project_ids", "in", [project_id]]],
["id", "name", "sequence", "fold"], limit=20, order="sequence asc",
)
except Exception:
stages = []
tasks_by_stage: dict = {}
for t in tasks:
stage_name = t["stage_id"][1] if t.get("stage_id") else "No Stage"
tasks_by_stage.setdefault(stage_name, []).append({
"id": t["id"], "name": t["name"],
"assignees": [u[1] for u in (t.get("user_ids") or []) if isinstance(u, list)],
"deadline": t.get("date_deadline"), "priority": t.get("priority"),
})
return {
"id": project_id, "name": p["name"],
"customer": p["partner_id"][1] if p.get("partner_id") else None,
"manager": p["user_id"][1] if p.get("user_id") else None,
"start_date": p.get("date_start"), "end_date": p.get("date"),
"description": p.get("description") or "",
"total_tasks": p.get("task_count", 0),
"stages": [s["name"] for s in stages],
"tasks_by_stage": tasks_by_stage,
}
def create_project(
name: str, customer_id: Optional[int] = None, manager_user_id: Optional[int] = None,
start_date: Optional[str] = None, end_date: Optional[str] = None,
description: Optional[str] = None, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
values: dict = {"name": name}
if customer_id:
values["partner_id"] = customer_id
if manager_user_id:
values["user_id"] = manager_user_id
if start_date:
values["date_start"] = start_date
if end_date:
values["date"] = end_date
if description:
values["description"] = description
try:
project_id = rpc.create("project.project", values)
except Exception as e:
return {"error": str(e)}
_audit("create_project", {"name": name}, f"Created project id={project_id}", instance)
return {"success": True, "project_id": project_id, "name": name,
"message": f"Project '{name}' created with id={project_id}"}
# ─── Tasks ────────────────────────────────────────────────────────────────────
def list_tasks(
project_id: Optional[int] = None, assignee_user_id: Optional[int] = None,
stage: Optional[str] = None, name: Optional[str] = None,
deadline_before: Optional[str] = None, include_done: bool = False,
instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = []
if project_id:
domain.append(["project_id", "=", project_id])
if assignee_user_id:
domain.append(["user_ids", "in", [assignee_user_id]])
if stage:
domain.append(["stage_id.name", "ilike", stage])
if name:
domain.append(["name", "ilike", name])
if deadline_before:
domain.append(["date_deadline", "<=", deadline_before])
if not include_done:
domain.append(["stage_id.fold", "!=", True])
try:
tasks = rpc.search_read(
"project.task", domain,
["id", "name", "project_id", "stage_id", "user_ids", "date_deadline", "priority"],
limit=200, order="date_deadline asc, name asc",
)
except Exception as e:
return {"error": str(e)}
return {
"tasks": [
{"id": t["id"], "name": t["name"],
"project": t["project_id"][1] if t.get("project_id") else None,
"project_id": t["project_id"][0] if t.get("project_id") else None,
"stage": t["stage_id"][1] if t.get("stage_id") else None,
"stage_id": t["stage_id"][0] if t.get("stage_id") else None,
"assignees": [u[1] for u in (t.get("user_ids") or []) if isinstance(u, list)],
"assignee_ids": [u[0] for u in (t.get("user_ids") or []) if isinstance(u, list)],
"deadline": t.get("date_deadline"),
"priority": "high" if t.get("priority") == "1" else "normal"}
for t in tasks
],
"total": len(tasks),
}
def create_task(
name: str, project_id: int,
assignee_user_ids: Optional[list[int]] = None, description: Optional[str] = None,
deadline: Optional[str] = None, priority: str = "normal",
stage_id: Optional[int] = None, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
values: dict = {"name": name, "project_id": project_id,
"priority": "1" if priority == "high" else "0"}
if assignee_user_ids:
values["user_ids"] = [(6, 0, assignee_user_ids)]
if description:
values["description"] = description
if deadline:
values["date_deadline"] = deadline
if stage_id:
values["stage_id"] = stage_id
try:
task_id = rpc.create("project.task", values)
except Exception as e:
_audit("create_task", {"name": name, "project_id": project_id}, "", instance, success=False, error=str(e))
return {"error": str(e)}
_audit("create_task", {"name": name, "project_id": project_id}, f"Created task id={task_id}", instance)
return {"success": True, "task_id": task_id, "name": name, "project_id": project_id,
"assignee_user_ids": assignee_user_ids or [],
"message": f"Task '{name}' created (id={task_id}) in project {project_id}"}
def assign_task(task_id: int, user_ids: list[int], replace: bool = True, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
values = {"user_ids": [(6, 0, user_ids)]} if replace else {"user_ids": [(4, uid) for uid in user_ids]}
try:
rpc.write("project.task", [task_id], values)
task = rpc.read("project.task", [task_id], ["name", "project_id", "user_ids"])
except Exception as e:
return {"error": str(e)}
task_data = task[0] if task else {}
assignees = [u[1] for u in (task_data.get("user_ids") or []) if isinstance(u, list)]
_audit("assign_task", {"task_id": task_id, "user_ids": user_ids},
f"Assigned task '{task_data.get('name')}'", instance)
return {"success": True, "task_id": task_id, "task_name": task_data.get("name"),
"project": task_data["project_id"][1] if task_data.get("project_id") else None,
"current_assignees": assignees,
"message": f"Task '{task_data.get('name')}' assigned to {len(user_ids)} user(s)."}
def update_task_stage(task_id: int, stage_id: int, instance: str = "default") -> dict:
rpc = get_conn_manager().rpc(instance)
try:
rpc.write("project.task", [task_id], {"stage_id": stage_id})
task = rpc.read("project.task", [task_id], ["name", "stage_id"])
stage = rpc.read("project.task.type", [stage_id], ["name"])
except Exception as e:
return {"error": str(e)}
task_data = task[0] if task else {}
stage_data = stage[0] if stage else {}
_audit("update_task_stage", {"task_id": task_id, "stage_id": stage_id},
f"Task moved to '{stage_data.get('name')}'", instance)
return {"success": True, "task_id": task_id, "task_name": task_data.get("name"),
"new_stage": stage_data.get("name"),
"message": f"Task moved to stage '{stage_data.get('name')}'."}
# ─── Customers / Sales ────────────────────────────────────────────────────────
def list_customers(
name: Optional[str] = None, email: Optional[str] = None,
is_company: Optional[bool] = None, limit: int = 50, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = [["customer_rank", ">", 0]]
if name:
domain.append(["name", "ilike", name])
if email:
domain.append(["email", "ilike", email])
if is_company is not None:
domain.append(["is_company", "=", is_company])
try:
partners = rpc.search_read(
"res.partner", domain,
["id", "name", "email", "phone", "is_company", "city", "country_id"],
limit=limit, order="name asc",
)
except Exception as e:
return {"error": str(e)}
return {
"customers": [
{"id": p["id"], "name": p["name"], "email": p.get("email"), "phone": p.get("phone"),
"type": "company" if p.get("is_company") else "individual",
"city": p.get("city"),
"country": p["country_id"][1] if p.get("country_id") else None}
for p in partners
],
"total": len(partners),
}
def list_sales_orders(
customer_id: Optional[int] = None, status: Optional[str] = None,
limit: int = 50, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = []
if customer_id:
domain.append(["partner_id", "=", customer_id])
if status:
domain.append(["state", "=", status])
try:
orders = rpc.search_read(
"sale.order", domain,
["id", "name", "partner_id", "state", "date_order", "amount_total", "user_id"],
limit=limit, order="date_order desc",
)
except Exception as e:
return {"error": str(e)}
status_labels = {"draft": "Quotation", "sent": "Sent", "sale": "Confirmed",
"done": "Locked", "cancel": "Cancelled"}
return {
"sales_orders": [
{"id": o["id"], "reference": o["name"],
"customer": o["partner_id"][1] if o.get("partner_id") else None,
"status": status_labels.get(o.get("state", ""), o.get("state")),
"state": o.get("state"), "order_date": o.get("date_order"),
"total": o.get("amount_total"),
"salesperson": o["user_id"][1] if o.get("user_id") else None}
for o in orders
],
"total": len(orders),
}
def list_invoices(
customer_id: Optional[int] = None, status: Optional[str] = None,
invoice_type: str = "out_invoice", limit: int = 50, instance: str = "default",
) -> dict:
rpc = get_conn_manager().rpc(instance)
domain: list = [["move_type", "=", invoice_type]]
if customer_id:
domain.append(["partner_id", "=", customer_id])
if status:
domain.append(["state", "=", status])
try:
invoices = rpc.search_read(
"account.move", domain,
["id", "name", "partner_id", "state", "invoice_date", "invoice_date_due",
"amount_total", "amount_residual"],
limit=limit, order="invoice_date desc",
)
except Exception as e:
return {"error": str(e)}
return {
"invoices": [
{"id": inv["id"], "reference": inv.get("name"),
"customer": inv["partner_id"][1] if inv.get("partner_id") else None,
"status": inv.get("state"), "invoice_date": inv.get("invoice_date"),
"due_date": inv.get("invoice_date_due"), "total": inv.get("amount_total"),
"amount_due": inv.get("amount_residual")}
for inv in invoices
],
"total": len(invoices),
}

0
tests/__init__.py Normal file
View File

123
tests/test_basic.py Normal file
View File

@ -0,0 +1,123 @@
"""Unit tests — no live Odoo or DB connection required."""
from __future__ import annotations
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from mt_odoo_mcp.confirmation.manager import ConfirmationManager
from mt_odoo_mcp.audit.logger import _sanitize
# ─── Confirmation Manager ──────────────────────────────────────────────────────
def test_token_created():
mgr = ConfirmationManager()
pending = mgr.create_token("default", "project.task", 42, "Fix Bug")
assert pending.token.startswith("del_")
assert pending.model == "project.task"
assert pending.record_id == 42
def test_token_consumed():
mgr = ConfirmationManager()
pending = mgr.create_token("default", "res.partner", 99, "ACME Corp")
ok, err = mgr.consume_token(pending.token, "res.partner", 99)
assert ok is True
assert err == ""
def test_token_single_use():
mgr = ConfirmationManager()
pending = mgr.create_token("default", "sale.order", 7, "SO007")
mgr.consume_token(pending.token, "sale.order", 7)
ok, err = mgr.consume_token(pending.token, "sale.order", 7)
assert ok is False
assert "expired" in err.lower() or "invalid" in err.lower()
def test_token_wrong_model():
mgr = ConfirmationManager()
pending = mgr.create_token("default", "project.task", 10, "Task A")
ok, err = mgr.consume_token(pending.token, "sale.order", 10)
assert ok is False
assert "does not match" in err.lower()
def test_token_wrong_record_id():
mgr = ConfirmationManager()
pending = mgr.create_token("default", "project.task", 10, "Task A")
ok, _ = mgr.consume_token(pending.token, "project.task", 999)
assert ok is False
def test_invalid_token():
mgr = ConfirmationManager()
ok, _ = mgr.consume_token("del_notarealtoken", "project.task", 1)
assert ok is False
def test_list_pending():
mgr = ConfirmationManager()
mgr.create_token("default", "account.move", 55, "INV/2026/001")
assert any(p["model"] == "account.move" for p in mgr.list_pending())
def test_per_user_isolation():
"""Two ConfirmationManager instances must not share tokens."""
mgr_a = ConfirmationManager()
mgr_b = ConfirmationManager()
pending = mgr_a.create_token("default", "project.task", 1, "Task")
ok, _ = mgr_b.consume_token(pending.token, "project.task", 1)
assert ok is False # mgr_b has no knowledge of mgr_a's tokens
# ─── Audit Logger Sanitization ─────────────────────────────────────────────────
def test_sanitize_password():
assert _sanitize({"username": "admin", "password": "s3cret"})["password"] == "***"
def test_sanitize_api_key():
result = _sanitize({"api_key": "abc123", "model": "project.task"})
assert result["api_key"] == "***"
assert result["model"] == "project.task"
def test_sanitize_nested():
result = _sanitize({"instance": {"name": "prod", "api_key": "secret"}})
assert result["instance"]["api_key"] == "***"
assert result["instance"]["name"] == "prod"
def test_sanitize_list_passthrough():
result = _sanitize({"ids": [1, 2, 3], "token": "tok_abc"})
assert result["ids"] == [1, 2, 3]
assert result["token"] == "***"
def test_sanitize_plain():
data = {"model": "sale.order", "limit": 10}
assert _sanitize(data) == data
# ─── Auth Crypto ──────────────────────────────────────────────────────────────
def test_password_hash_verify():
from mt_odoo_mcp.auth.crypto import hash_password, verify_password
h = hash_password("mysecret123")
assert verify_password("mysecret123", h)
assert not verify_password("wrong", h)
def test_api_key_format():
from mt_odoo_mcp.auth.crypto import generate_api_key
full, key_hash, prefix = generate_api_key()
assert full.startswith("mtom_")
assert len(full) > 20
assert prefix == full[:12]
def test_api_key_verify():
from mt_odoo_mcp.auth.crypto import generate_api_key, verify_api_key
full, key_hash, _ = generate_api_key()
assert verify_api_key(full, key_hash)
assert not verify_api_key("mtom_wrong", key_hash)