From b2ca96066b1c28cf4d11e2ebf4e9d2b3e92ab50a Mon Sep 17 00:00:00 2001 From: MOHAN Date: Mon, 29 Jun 2026 09:10:05 +0530 Subject: [PATCH] fixed known bugs_new --- .env.example | 24 + .gitignore | 15 + pyproject.toml | 29 + src/mt_odoo_mcp/__init__.py | 0 src/mt_odoo_mcp/api/__init__.py | 0 src/mt_odoo_mcp/api/routes.py | 196 +++++++ src/mt_odoo_mcp/audit/__init__.py | 0 src/mt_odoo_mcp/audit/logger.py | 80 +++ src/mt_odoo_mcp/auth/__init__.py | 0 src/mt_odoo_mcp/auth/crypto.py | 52 ++ src/mt_odoo_mcp/auth/models.py | 25 + src/mt_odoo_mcp/auth/service.py | 189 +++++++ src/mt_odoo_mcp/client/__init__.py | 0 src/mt_odoo_mcp/client/connection.py | 82 +++ src/mt_odoo_mcp/client/rpc.py | 77 +++ src/mt_odoo_mcp/config.py | 29 + src/mt_odoo_mcp/confirmation/__init__.py | 0 src/mt_odoo_mcp/confirmation/manager.py | 73 +++ src/mt_odoo_mcp/context.py | 45 ++ src/mt_odoo_mcp/database.py | 71 +++ src/mt_odoo_mcp/registry.py | 43 ++ src/mt_odoo_mcp/server.py | 669 +++++++++++++++++++++++ src/mt_odoo_mcp/tools/__init__.py | 0 src/mt_odoo_mcp/tools/a_connection.py | 80 +++ src/mt_odoo_mcp/tools/b_discovery.py | 121 ++++ src/mt_odoo_mcp/tools/c_records.py | 259 +++++++++ src/mt_odoo_mcp/tools/d_workflow.py | 154 ++++++ src/mt_odoo_mcp/tools/e_smart.py | 487 +++++++++++++++++ tests/__init__.py | 0 tests/test_basic.py | 123 +++++ 30 files changed, 2923 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/mt_odoo_mcp/__init__.py create mode 100644 src/mt_odoo_mcp/api/__init__.py create mode 100644 src/mt_odoo_mcp/api/routes.py create mode 100644 src/mt_odoo_mcp/audit/__init__.py create mode 100644 src/mt_odoo_mcp/audit/logger.py create mode 100644 src/mt_odoo_mcp/auth/__init__.py create mode 100644 src/mt_odoo_mcp/auth/crypto.py create mode 100644 src/mt_odoo_mcp/auth/models.py create mode 100644 src/mt_odoo_mcp/auth/service.py create mode 100644 src/mt_odoo_mcp/client/__init__.py create mode 100644 src/mt_odoo_mcp/client/connection.py create mode 100644 src/mt_odoo_mcp/client/rpc.py create mode 100644 src/mt_odoo_mcp/config.py create mode 100644 src/mt_odoo_mcp/confirmation/__init__.py create mode 100644 src/mt_odoo_mcp/confirmation/manager.py create mode 100644 src/mt_odoo_mcp/context.py create mode 100644 src/mt_odoo_mcp/database.py create mode 100644 src/mt_odoo_mcp/registry.py create mode 100644 src/mt_odoo_mcp/server.py create mode 100644 src/mt_odoo_mcp/tools/__init__.py create mode 100644 src/mt_odoo_mcp/tools/a_connection.py create mode 100644 src/mt_odoo_mcp/tools/b_discovery.py create mode 100644 src/mt_odoo_mcp/tools/c_records.py create mode 100644 src/mt_odoo_mcp/tools/d_workflow.py create mode 100644 src/mt_odoo_mcp/tools/e_smart.py create mode 100644 tests/__init__.py create mode 100644 tests/test_basic.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d1e6e27 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01cdae6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..faffb64 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/mt_odoo_mcp/__init__.py b/src/mt_odoo_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/api/__init__.py b/src/mt_odoo_mcp/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/api/routes.py b/src/mt_odoo_mcp/api/routes.py new file mode 100644 index 0000000..7b1f5af --- /dev/null +++ b/src/mt_odoo_mcp/api/routes.py @@ -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 = 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://:/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://:/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."} diff --git a/src/mt_odoo_mcp/audit/__init__.py b/src/mt_odoo_mcp/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/audit/logger.py b/src/mt_odoo_mcp/audit/logger.py new file mode 100644 index 0000000..0660b11 --- /dev/null +++ b/src/mt_odoo_mcp/audit/logger.py @@ -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() diff --git a/src/mt_odoo_mcp/auth/__init__.py b/src/mt_odoo_mcp/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/auth/crypto.py b/src/mt_odoo_mcp/auth/crypto.py new file mode 100644 index 0000000..d2bbce0 --- /dev/null +++ b/src/mt_odoo_mcp/auth/crypto.py @@ -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() diff --git a/src/mt_odoo_mcp/auth/models.py b/src/mt_odoo_mcp/auth/models.py new file mode 100644 index 0000000..9579f7d --- /dev/null +++ b/src/mt_odoo_mcp/auth/models.py @@ -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 diff --git a/src/mt_odoo_mcp/auth/service.py b/src/mt_odoo_mcp/auth/service.py new file mode 100644 index 0000000..c2fb720 --- /dev/null +++ b/src/mt_odoo_mcp/auth/service.py @@ -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"], + ) diff --git a/src/mt_odoo_mcp/client/__init__.py b/src/mt_odoo_mcp/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/client/connection.py b/src/mt_odoo_mcp/client/connection.py new file mode 100644 index 0000000..da18fa2 --- /dev/null +++ b/src/mt_odoo_mcp/client/connection.py @@ -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) diff --git a/src/mt_odoo_mcp/client/rpc.py b/src/mt_odoo_mcp/client/rpc.py new file mode 100644 index 0000000..8334a54 --- /dev/null +++ b/src/mt_odoo_mcp/client/rpc.py @@ -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") diff --git a/src/mt_odoo_mcp/config.py b/src/mt_odoo_mcp/config.py new file mode 100644 index 0000000..cf3e04e --- /dev/null +++ b/src/mt_odoo_mcp/config.py @@ -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() diff --git a/src/mt_odoo_mcp/confirmation/__init__.py b/src/mt_odoo_mcp/confirmation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/confirmation/manager.py b/src/mt_odoo_mcp/confirmation/manager.py new file mode 100644 index 0000000..ea1ddd8 --- /dev/null +++ b/src/mt_odoo_mcp/confirmation/manager.py @@ -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() + ] diff --git a/src/mt_odoo_mcp/context.py b/src/mt_odoo_mcp/context.py new file mode 100644 index 0000000..b8f48b1 --- /dev/null +++ b/src/mt_odoo_mcp/context.py @@ -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]) diff --git a/src/mt_odoo_mcp/database.py b/src/mt_odoo_mcp/database.py new file mode 100644 index 0000000..1dbfcdf --- /dev/null +++ b/src/mt_odoo_mcp/database.py @@ -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() diff --git a/src/mt_odoo_mcp/registry.py b/src/mt_odoo_mcp/registry.py new file mode 100644 index 0000000..49a0489 --- /dev/null +++ b/src/mt_odoo_mcp/registry.py @@ -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) diff --git a/src/mt_odoo_mcp/server.py b/src/mt_odoo_mcp/server.py new file mode 100644 index 0000000..fded644 --- /dev/null +++ b/src/mt_odoo_mcp/server.py @@ -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= 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= or Authorization: Bearer ", + ) + + 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() diff --git a/src/mt_odoo_mcp/tools/__init__.py b/src/mt_odoo_mcp/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mt_odoo_mcp/tools/a_connection.py b/src/mt_odoo_mcp/tools/a_connection.py new file mode 100644 index 0000000..9693cdd --- /dev/null +++ b/src/mt_odoo_mcp/tools/a_connection.py @@ -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)} diff --git a/src/mt_odoo_mcp/tools/b_discovery.py b/src/mt_odoo_mcp/tools/b_discovery.py new file mode 100644 index 0000000..61b472d --- /dev/null +++ b/src/mt_odoo_mcp/tools/b_discovery.py @@ -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} diff --git a/src/mt_odoo_mcp/tools/c_records.py b/src/mt_odoo_mcp/tools/c_records.py new file mode 100644 index 0000000..578ae42 --- /dev/null +++ b/src/mt_odoo_mcp/tools/c_records.py @@ -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} diff --git a/src/mt_odoo_mcp/tools/d_workflow.py b/src/mt_odoo_mcp/tools/d_workflow.py new file mode 100644 index 0000000..b41dfeb --- /dev/null +++ b/src/mt_odoo_mcp/tools/d_workflow.py @@ -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)} diff --git a/src/mt_odoo_mcp/tools/e_smart.py b/src/mt_odoo_mcp/tools/e_smart.py new file mode 100644 index 0000000..e55ae7c --- /dev/null +++ b/src/mt_odoo_mcp/tools/e_smart.py @@ -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), + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..970c4dd --- /dev/null +++ b/tests/test_basic.py @@ -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)