fixed known bugs_new
This commit is contained in:
commit
b2ca96066b
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
||||
# ─── Server ──────────────────────────────────────────────────────────────────
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
MCP_SERVER_NAME=mt-odoo-mcp
|
||||
MCP_LOG_LEVEL=INFO
|
||||
|
||||
# ─── Security (generate strong random values for production) ─────────────────
|
||||
# Used to sign API keys and session tokens — keep SECRET
|
||||
SECRET_KEY=change-me-to-a-long-random-string-in-production
|
||||
|
||||
# Fernet encryption key for Odoo credentials stored in the DB.
|
||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
ENCRYPTION_KEY=change-me-to-a-fernet-key-in-production
|
||||
|
||||
# ─── Database ─────────────────────────────────────────────────────────────────
|
||||
# SQLite path (relative to working directory, or absolute)
|
||||
DATABASE_PATH=./mt_odoo_mcp.db
|
||||
|
||||
# ─── Delete Confirmation ─────────────────────────────────────────────────────
|
||||
CONFIRM_TOKEN_TTL=300
|
||||
|
||||
# ─── Audit Log ───────────────────────────────────────────────────────────────
|
||||
AUDIT_LOG_FILE=./audit.jsonl
|
||||
AUDIT_LOG_ENABLED=true
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
audit.jsonl
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@ -0,0 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mt-odoo-mcp"
|
||||
version = "1.0.0"
|
||||
description = "Multi-tenant MCP server for Odoo — users self-register and manage their own Odoo instances via AI agents"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.0.0",
|
||||
"pydantic>=2.7.0",
|
||||
"pydantic-settings>=2.3.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"anyio>=4.4.0",
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"cryptography>=42.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mt-odoo-mcp = "mt_odoo_mcp.server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mt_odoo_mcp"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
0
src/mt_odoo_mcp/__init__.py
Normal file
0
src/mt_odoo_mcp/__init__.py
Normal file
0
src/mt_odoo_mcp/api/__init__.py
Normal file
0
src/mt_odoo_mcp/api/__init__.py
Normal file
196
src/mt_odoo_mcp/api/routes.py
Normal file
196
src/mt_odoo_mcp/api/routes.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""REST API routes — user signup, login, credential management."""
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from ..auth.service import (
|
||||
AuthError, signup, login, get_user_by_api_key,
|
||||
upsert_odoo_credential, list_odoo_credentials,
|
||||
delete_odoo_credential, regenerate_api_key,
|
||||
)
|
||||
from ..registry import refresh_conn_manager, evict_user
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
# ─── Request / Response models ────────────────────────────────────────────────
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
# Optional: provide Odoo credentials right at signup
|
||||
odoo_url: Optional[str] = None
|
||||
odoo_database: Optional[str] = None
|
||||
odoo_username: Optional[str] = None
|
||||
odoo_credential: Optional[str] = None
|
||||
odoo_is_api_key: bool = True
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class CredentialRequest(BaseModel):
|
||||
instance_name: str = "default"
|
||||
url: str
|
||||
database: str
|
||||
username: str
|
||||
credential: str
|
||||
is_api_key: bool = True
|
||||
|
||||
|
||||
# ─── Auth dependency ──────────────────────────────────────────────────────────
|
||||
|
||||
def _require_auth(authorization: Optional[str] = Header(None)):
|
||||
"""Extract and validate Bearer API key from Authorization header."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header. Use: Bearer <api_key>")
|
||||
api_key = authorization.removeprefix("Bearer ").strip()
|
||||
user = get_user_by_api_key(api_key)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired API key.")
|
||||
return user
|
||||
|
||||
|
||||
# ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/signup", status_code=201)
|
||||
def api_signup(body: SignupRequest):
|
||||
"""Register a new user. Returns the API key (shown once — save it!)."""
|
||||
try:
|
||||
user, full_api_key = signup(body.email, body.password)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# If Odoo credentials provided at signup, store them immediately
|
||||
if body.odoo_url and body.odoo_database and body.odoo_username and body.odoo_credential:
|
||||
try:
|
||||
upsert_odoo_credential(
|
||||
user_id=user.id,
|
||||
instance_name="default",
|
||||
url=body.odoo_url,
|
||||
database_name=body.odoo_database,
|
||||
odoo_username=body.odoo_username,
|
||||
credential=body.odoo_credential,
|
||||
is_api_key=body.odoo_is_api_key,
|
||||
)
|
||||
refresh_conn_manager(user.id)
|
||||
except Exception as e:
|
||||
# Don't fail signup if credential storage fails; user can add later
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": "Account created successfully.",
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"api_key": full_api_key,
|
||||
"warning": "Save your API key — it will not be shown again.",
|
||||
"mcp_connection": {
|
||||
"how_to_connect": "Set this in your MCP client config",
|
||||
"url": "http://<your-vps-host>:<port>/mcp/sse",
|
||||
"header": f"Authorization: Bearer {full_api_key}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def api_login(body: LoginRequest):
|
||||
"""Verify email + password. Returns account info (not the API key — use /api/api-key to regenerate)."""
|
||||
try:
|
||||
user = login(body.email, body.password)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"api_key_prefix": user.api_key_prefix + "...",
|
||||
"created_at": user.created_at,
|
||||
"message": "Login successful. Use your API key to connect MCP clients.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def api_me(authorization: Optional[str] = Header(None)):
|
||||
user = _require_auth(authorization)
|
||||
creds = list_odoo_credentials(user.id)
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"api_key_prefix": user.api_key_prefix + "...",
|
||||
"created_at": user.created_at,
|
||||
"odoo_instances": [
|
||||
{"instance_name": c.instance_name, "url": c.url,
|
||||
"database": c.database_name, "username": c.odoo_username,
|
||||
"type": "api_key" if c.is_api_key else "password"}
|
||||
for c in creds
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api-key/regenerate")
|
||||
def api_regen_key(authorization: Optional[str] = Header(None)):
|
||||
"""Generate a new API key, invalidating the old one. Shown once."""
|
||||
user = _require_auth(authorization)
|
||||
new_key = regenerate_api_key(user.id)
|
||||
return {
|
||||
"api_key": new_key,
|
||||
"warning": "Your old API key is now invalid. Save this new key — it will not be shown again.",
|
||||
"mcp_connection": {
|
||||
"url": "http://<your-vps-host>:<port>/mcp/sse",
|
||||
"header": f"Authorization: Bearer {new_key}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/credentials")
|
||||
def api_add_credential(body: CredentialRequest, authorization: Optional[str] = Header(None)):
|
||||
"""Add or update an Odoo instance credential."""
|
||||
user = _require_auth(authorization)
|
||||
try:
|
||||
cred = upsert_odoo_credential(
|
||||
user_id=user.id,
|
||||
instance_name=body.instance_name,
|
||||
url=body.url,
|
||||
database_name=body.database,
|
||||
odoo_username=body.username,
|
||||
credential=body.credential,
|
||||
is_api_key=body.is_api_key,
|
||||
)
|
||||
refresh_conn_manager(user.id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"success": True,
|
||||
"instance_name": cred.instance_name,
|
||||
"url": cred.url,
|
||||
"database": cred.database_name,
|
||||
"username": cred.odoo_username,
|
||||
"message": f"Odoo instance '{cred.instance_name}' saved.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/credentials")
|
||||
def api_list_credentials(authorization: Optional[str] = Header(None)):
|
||||
user = _require_auth(authorization)
|
||||
creds = list_odoo_credentials(user.id)
|
||||
return {
|
||||
"instances": [
|
||||
{"instance_name": c.instance_name, "url": c.url,
|
||||
"database": c.database_name, "username": c.odoo_username,
|
||||
"type": "api_key" if c.is_api_key else "password",
|
||||
"created_at": c.created_at}
|
||||
for c in creds
|
||||
],
|
||||
"total": len(creds),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/credentials/{instance_name}")
|
||||
def api_delete_credential(instance_name: str, authorization: Optional[str] = Header(None)):
|
||||
user = _require_auth(authorization)
|
||||
deleted = delete_odoo_credential(user.id, instance_name)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Instance '{instance_name}' not found.")
|
||||
refresh_conn_manager(user.id)
|
||||
return {"success": True, "message": f"Instance '{instance_name}' removed."}
|
||||
0
src/mt_odoo_mcp/audit/__init__.py
Normal file
0
src/mt_odoo_mcp/audit/__init__.py
Normal file
80
src/mt_odoo_mcp/audit/logger.py
Normal file
80
src/mt_odoo_mcp/audit/logger.py
Normal file
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from ..config import settings
|
||||
|
||||
_SENSITIVE = {"password", "api_key", "token", "secret", "passwd", "credential"}
|
||||
|
||||
|
||||
def _sanitize(obj: Any, depth: int = 0) -> Any:
|
||||
if depth > 5:
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: "***" if k.lower() in _SENSITIVE else _sanitize(v, depth + 1)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_sanitize(i, depth + 1) for i in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
_instance: Optional["AuditLogger"] = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._lock = threading.Lock()
|
||||
return cls._instance
|
||||
|
||||
def log(
|
||||
self,
|
||||
tool: str,
|
||||
params: dict,
|
||||
result_summary: str,
|
||||
instance: str = "default",
|
||||
user_id: Optional[int] = None,
|
||||
user_email: Optional[str] = None,
|
||||
success: bool = True,
|
||||
error: Optional[str] = None,
|
||||
):
|
||||
if not settings.audit_log_enabled:
|
||||
return
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
"tool": tool,
|
||||
"instance": instance,
|
||||
"success": success,
|
||||
"params": _sanitize(params),
|
||||
"result": result_summary,
|
||||
"error": error,
|
||||
}
|
||||
path = Path(settings.audit_log_file)
|
||||
with self._lock:
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
def read_recent(self, n: int = 50, user_id: Optional[int] = None) -> list[dict]:
|
||||
path = Path(settings.audit_log_file)
|
||||
if not path.exists():
|
||||
return []
|
||||
with self._lock:
|
||||
lines = path.read_text(encoding="utf-8").strip().splitlines()
|
||||
entries = []
|
||||
for line in lines:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
if user_id is None or e.get("user_id") == user_id:
|
||||
entries.append(e)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return list(reversed(entries[-n:]))
|
||||
|
||||
|
||||
audit_logger = AuditLogger()
|
||||
0
src/mt_odoo_mcp/auth/__init__.py
Normal file
0
src/mt_odoo_mcp/auth/__init__.py
Normal file
52
src/mt_odoo_mcp/auth/crypto.py
Normal file
52
src/mt_odoo_mcp/auth/crypto.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Password hashing, API key generation, and Fernet encryption for credentials."""
|
||||
from __future__ import annotations
|
||||
import secrets
|
||||
from passlib.context import CryptContext
|
||||
from cryptography.fernet import Fernet
|
||||
from ..config import settings
|
||||
|
||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return _pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return _pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def generate_api_key() -> tuple[str, str, str]:
|
||||
"""Returns (full_key, key_hash, key_prefix).
|
||||
|
||||
full_key is shown to the user exactly once.
|
||||
key_hash is stored in the DB.
|
||||
key_prefix (first 8 chars) is stored for display.
|
||||
"""
|
||||
raw = secrets.token_hex(32)
|
||||
full_key = f"mtom_{raw}"
|
||||
key_hash = _pwd_context.hash(full_key)
|
||||
key_prefix = full_key[:12]
|
||||
return full_key, key_hash, key_prefix
|
||||
|
||||
|
||||
def verify_api_key(plain: str, hashed: str) -> bool:
|
||||
return _pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = settings.encryption_key
|
||||
if key == "change-me":
|
||||
raise RuntimeError("ENCRYPTION_KEY is not set. Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"")
|
||||
try:
|
||||
return Fernet(key.encode())
|
||||
except Exception:
|
||||
raise RuntimeError("ENCRYPTION_KEY is not a valid Fernet key.")
|
||||
|
||||
|
||||
def encrypt_credential(plain: str) -> str:
|
||||
return _fernet().encrypt(plain.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_credential(enc: str) -> str:
|
||||
return _fernet().decrypt(enc.encode()).decode()
|
||||
25
src/mt_odoo_mcp/auth/models.py
Normal file
25
src/mt_odoo_mcp/auth/models.py
Normal file
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: int
|
||||
email: str
|
||||
api_key_prefix: str
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class OdooCredential:
|
||||
id: int
|
||||
user_id: int
|
||||
instance_name: str
|
||||
url: str
|
||||
database_name: str
|
||||
odoo_username: str
|
||||
credential_enc: str
|
||||
is_api_key: bool
|
||||
created_at: str
|
||||
189
src/mt_odoo_mcp/auth/service.py
Normal file
189
src/mt_odoo_mcp/auth/service.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""Auth business logic — signup, login, API key management, credential CRUD."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from .models import User, OdooCredential
|
||||
from .crypto import (
|
||||
hash_password, verify_password,
|
||||
generate_api_key, verify_api_key,
|
||||
encrypt_credential, decrypt_credential,
|
||||
)
|
||||
from ..database import db_execute, db_commit, db_fetchone, db_fetchall
|
||||
from ..config import OdooInstanceConfig as _Cfg
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ─── OdooInstanceConfig re-export for connection layer ────────────────────────
|
||||
|
||||
class OdooInstanceConfig:
|
||||
def __init__(self, name: str, url: str, db: str, username: str, credential: str):
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.db = db
|
||||
self.username = username
|
||||
self._credential = credential
|
||||
|
||||
@property
|
||||
def credential(self) -> str:
|
||||
return self._credential
|
||||
|
||||
|
||||
# ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def signup(email: str, password: str) -> tuple[User, str]:
|
||||
"""Create a new user. Returns (user, full_api_key). API key shown only once."""
|
||||
email = email.strip().lower()
|
||||
if not email or not password:
|
||||
raise AuthError("Email and password are required.")
|
||||
if len(password) < 8:
|
||||
raise AuthError("Password must be at least 8 characters.")
|
||||
|
||||
existing = db_fetchone("SELECT id FROM users WHERE email = ?", (email,))
|
||||
if existing:
|
||||
raise AuthError("An account with this email already exists.")
|
||||
|
||||
pw_hash = hash_password(password)
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
|
||||
db_execute(
|
||||
"INSERT INTO users (email, password_hash, api_key_hash, api_key_prefix) VALUES (?, ?, ?, ?)",
|
||||
(email, pw_hash, key_hash, key_prefix),
|
||||
)
|
||||
db_commit()
|
||||
|
||||
row = db_fetchone("SELECT * FROM users WHERE email = ?", (email,))
|
||||
user = _row_to_user(row)
|
||||
return user, full_key
|
||||
|
||||
|
||||
def login(email: str, password: str) -> User:
|
||||
"""Verify email + password. Returns the user or raises AuthError."""
|
||||
email = email.strip().lower()
|
||||
row = db_fetchone("SELECT * FROM users WHERE email = ?", (email,))
|
||||
if not row or not verify_password(password, row["password_hash"]):
|
||||
raise AuthError("Invalid email or password.")
|
||||
if not row["is_active"]:
|
||||
raise AuthError("Account is disabled.")
|
||||
return _row_to_user(row)
|
||||
|
||||
|
||||
def get_user_by_api_key(api_key: str) -> Optional[User]:
|
||||
"""Look up a user by their API key. Returns None if not found/invalid."""
|
||||
if not api_key or not api_key.startswith("mtom_"):
|
||||
return None
|
||||
prefix = api_key[:12]
|
||||
rows = db_fetchall("SELECT * FROM users WHERE api_key_prefix = ? AND is_active = 1", (prefix,))
|
||||
for row in rows:
|
||||
if verify_api_key(api_key, row["api_key_hash"]):
|
||||
return _row_to_user(row)
|
||||
return None
|
||||
|
||||
|
||||
def regenerate_api_key(user_id: int) -> str:
|
||||
"""Generate a new API key for the user. Returns the full key (shown once)."""
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db_execute(
|
||||
"UPDATE users SET api_key_hash = ?, api_key_prefix = ? WHERE id = ?",
|
||||
(key_hash, key_prefix, user_id),
|
||||
)
|
||||
db_commit()
|
||||
return full_key
|
||||
|
||||
|
||||
def get_user_by_id(user_id: int) -> Optional[User]:
|
||||
row = db_fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
return _row_to_user(row) if row else None
|
||||
|
||||
|
||||
def _row_to_user(row) -> User:
|
||||
return User(
|
||||
id=row["id"],
|
||||
email=row["email"],
|
||||
api_key_prefix=row["api_key_prefix"],
|
||||
is_active=bool(row["is_active"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
# ─── Odoo Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_odoo_credential(
|
||||
user_id: int,
|
||||
instance_name: str,
|
||||
url: str,
|
||||
database_name: str,
|
||||
odoo_username: str,
|
||||
credential: str,
|
||||
is_api_key: bool = True,
|
||||
) -> OdooCredential:
|
||||
"""Add or replace an Odoo instance credential for a user."""
|
||||
enc = encrypt_credential(credential)
|
||||
existing = db_fetchone(
|
||||
"SELECT id FROM odoo_credentials WHERE user_id = ? AND instance_name = ?",
|
||||
(user_id, instance_name),
|
||||
)
|
||||
if existing:
|
||||
db_execute(
|
||||
"""UPDATE odoo_credentials
|
||||
SET url=?, database_name=?, odoo_username=?, credential_enc=?, is_api_key=?
|
||||
WHERE user_id=? AND instance_name=?""",
|
||||
(url, database_name, odoo_username, enc, int(is_api_key), user_id, instance_name),
|
||||
)
|
||||
else:
|
||||
db_execute(
|
||||
"""INSERT INTO odoo_credentials
|
||||
(user_id, instance_name, url, database_name, odoo_username, credential_enc, is_api_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, instance_name, url, database_name, odoo_username, enc, int(is_api_key)),
|
||||
)
|
||||
db_commit()
|
||||
row = db_fetchone(
|
||||
"SELECT * FROM odoo_credentials WHERE user_id=? AND instance_name=?",
|
||||
(user_id, instance_name),
|
||||
)
|
||||
return _row_to_cred(row)
|
||||
|
||||
|
||||
def list_odoo_credentials(user_id: int) -> list[OdooCredential]:
|
||||
rows = db_fetchall("SELECT * FROM odoo_credentials WHERE user_id=? ORDER BY instance_name", (user_id,))
|
||||
return [_row_to_cred(r) for r in rows]
|
||||
|
||||
|
||||
def delete_odoo_credential(user_id: int, instance_name: str) -> bool:
|
||||
cur = db_execute(
|
||||
"DELETE FROM odoo_credentials WHERE user_id=? AND instance_name=?",
|
||||
(user_id, instance_name),
|
||||
)
|
||||
db_commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_odoo_instance_configs(user_id: int) -> list[OdooInstanceConfig]:
|
||||
"""Return decrypted Odoo configs for this user, ready for ConnectionManager."""
|
||||
creds = list_odoo_credentials(user_id)
|
||||
return [
|
||||
OdooInstanceConfig(
|
||||
name=c.instance_name,
|
||||
url=c.url,
|
||||
db=c.database_name,
|
||||
username=c.odoo_username,
|
||||
credential=decrypt_credential(c.credential_enc),
|
||||
)
|
||||
for c in creds
|
||||
]
|
||||
|
||||
|
||||
def _row_to_cred(row) -> OdooCredential:
|
||||
return OdooCredential(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
instance_name=row["instance_name"],
|
||||
url=row["url"],
|
||||
database_name=row["database_name"],
|
||||
odoo_username=row["odoo_username"],
|
||||
credential_enc=row["credential_enc"],
|
||||
is_api_key=bool(row["is_api_key"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
0
src/mt_odoo_mcp/client/__init__.py
Normal file
0
src/mt_odoo_mcp/client/__init__.py
Normal file
82
src/mt_odoo_mcp/client/connection.py
Normal file
82
src/mt_odoo_mcp/client/connection.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Per-user (non-singleton) connection manager."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from .rpc import OdooRPC
|
||||
|
||||
|
||||
@dataclass
|
||||
class OdooSession:
|
||||
name: str
|
||||
url: str
|
||||
db: str
|
||||
username: str
|
||||
uid: int
|
||||
version: str
|
||||
rpc: OdooRPC
|
||||
connected: bool = True
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""One instance per user. Holds all Odoo sessions for that user."""
|
||||
|
||||
def __init__(self, configs: list):
|
||||
self._sessions: dict[str, OdooSession] = {}
|
||||
self._configs: dict[str, object] = {cfg.name: cfg for cfg in configs}
|
||||
|
||||
def reload_configs(self, configs: list):
|
||||
"""Replace credential list (e.g. after the user adds a new instance)."""
|
||||
self._configs = {cfg.name: cfg for cfg in configs}
|
||||
|
||||
def connect(self, cfg) -> OdooSession:
|
||||
uid, version = OdooRPC.authenticate(cfg.url, cfg.db, cfg.username, cfg.credential)
|
||||
rpc = OdooRPC(cfg.url, cfg.db, uid, cfg.credential)
|
||||
session = OdooSession(
|
||||
name=cfg.name, url=cfg.url, db=cfg.db,
|
||||
username=cfg.username, uid=uid, version=version, rpc=rpc,
|
||||
)
|
||||
self._sessions[cfg.name] = session
|
||||
return session
|
||||
|
||||
def connect_all(self):
|
||||
for cfg in self._configs.values():
|
||||
try:
|
||||
self.connect(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def ensure_connected(self, instance: str = "default") -> OdooSession:
|
||||
if instance not in self._sessions or not self._sessions[instance].connected:
|
||||
cfg = self._configs.get(instance)
|
||||
if not cfg:
|
||||
available = list(self._configs.keys())
|
||||
raise ValueError(
|
||||
f"Odoo instance '{instance}' not configured. "
|
||||
f"Available: {available}. "
|
||||
f"Add it via POST /api/credentials."
|
||||
)
|
||||
self.connect(cfg)
|
||||
return self._sessions[instance]
|
||||
|
||||
def get(self, instance: str = "default") -> OdooSession:
|
||||
return self.ensure_connected(instance)
|
||||
|
||||
def rpc(self, instance: str = "default") -> OdooRPC:
|
||||
return self.get(instance).rpc
|
||||
|
||||
def list_instances(self) -> list[dict]:
|
||||
result = []
|
||||
for name, cfg in self._configs.items():
|
||||
session = self._sessions.get(name)
|
||||
result.append({
|
||||
"name": name,
|
||||
"url": cfg.url,
|
||||
"db": cfg.db,
|
||||
"username": cfg.username,
|
||||
"connected": session is not None and session.connected,
|
||||
"version": session.version if session else None,
|
||||
})
|
||||
return result
|
||||
|
||||
def disconnect(self, instance: str):
|
||||
self._sessions.pop(instance, None)
|
||||
77
src/mt_odoo_mcp/client/rpc.py
Normal file
77
src/mt_odoo_mcp/client/rpc.py
Normal file
@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import xmlrpc.client
|
||||
from typing import Any
|
||||
|
||||
|
||||
class OdooRPC:
|
||||
"""Thread-safe XML-RPC wrapper for Odoo's /xmlrpc/2/object endpoint."""
|
||||
|
||||
def __init__(self, url: str, db: str, uid: int, credential: str):
|
||||
self._url = url.rstrip("/")
|
||||
self._db = db
|
||||
self._uid = uid
|
||||
self._credential = credential
|
||||
self._lock = threading.Lock()
|
||||
self._proxy = xmlrpc.client.ServerProxy(f"{self._url}/xmlrpc/2/object")
|
||||
|
||||
def _call(self, model: str, method: str, args: list, kwargs: dict | None = None) -> Any:
|
||||
with self._lock:
|
||||
return self._proxy.execute_kw(
|
||||
self._db, self._uid, self._credential,
|
||||
model, method, args, kwargs or {}
|
||||
)
|
||||
|
||||
def search(self, model: str, domain: list, *, limit: int = 100, offset: int = 0, order: str = "") -> list[int]:
|
||||
kw: dict = {"limit": limit, "offset": offset}
|
||||
if order:
|
||||
kw["order"] = order
|
||||
return self._call(model, "search", [domain], kw)
|
||||
|
||||
def search_read(self, model: str, domain: list, fields: list[str], *,
|
||||
limit: int = 100, offset: int = 0, order: str = "") -> list[dict]:
|
||||
kw: dict = {"fields": fields, "limit": limit, "offset": offset}
|
||||
if order:
|
||||
kw["order"] = order
|
||||
return self._call(model, "search_read", [domain], kw)
|
||||
|
||||
def read(self, model: str, ids: list[int], fields: list[str]) -> list[dict]:
|
||||
return self._call(model, "read", [ids], {"fields": fields})
|
||||
|
||||
def count(self, model: str, domain: list) -> int:
|
||||
return self._call(model, "search_count", [domain], {})
|
||||
|
||||
def create(self, model: str, values: dict) -> int:
|
||||
return self._call(model, "create", [values], {})
|
||||
|
||||
def write(self, model: str, ids: list[int], values: dict) -> bool:
|
||||
return self._call(model, "write", [ids, values], {})
|
||||
|
||||
def unlink(self, model: str, ids: list[int]) -> bool:
|
||||
return self._call(model, "unlink", [ids], {})
|
||||
|
||||
def fields_get(self, model: str, attrs: list[str] | None = None) -> dict:
|
||||
kw: dict = {}
|
||||
if attrs:
|
||||
kw["attributes"] = attrs
|
||||
return self._call(model, "fields_get", [], kw)
|
||||
|
||||
def execute(self, model: str, method: str, *args, **kwargs) -> Any:
|
||||
return self._call(model, method, list(args), kwargs)
|
||||
|
||||
def model_exists(self, model: str) -> bool:
|
||||
try:
|
||||
self._call("ir.model", "search_count", [[["model", "=", model]]], {})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def authenticate(url: str, db: str, username: str, credential: str) -> tuple[int, str]:
|
||||
"""Returns (uid, server_version). Raises on failure."""
|
||||
common = xmlrpc.client.ServerProxy(f"{url.rstrip('/')}/xmlrpc/2/common")
|
||||
uid = common.authenticate(db, username, credential, {})
|
||||
if not uid:
|
||||
raise ValueError(f"Authentication failed for user '{username}' on '{url}' db='{db}'")
|
||||
info = common.version()
|
||||
return uid, info.get("server_version", "unknown")
|
||||
29
src/mt_odoo_mcp/config.py
Normal file
29
src/mt_odoo_mcp/config.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
mcp_server_name: str = "mt-odoo-mcp"
|
||||
mcp_log_level: str = "INFO"
|
||||
|
||||
# Security
|
||||
secret_key: str = "change-me"
|
||||
encryption_key: str = "change-me"
|
||||
|
||||
# Database
|
||||
database_path: str = "./mt_odoo_mcp.db"
|
||||
|
||||
# Delete confirmation token TTL (seconds)
|
||||
confirm_token_ttl: int = 300
|
||||
|
||||
# Audit
|
||||
audit_log_file: str = "./audit.jsonl"
|
||||
audit_log_enabled: bool = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
src/mt_odoo_mcp/confirmation/__init__.py
Normal file
0
src/mt_odoo_mcp/confirmation/__init__.py
Normal file
73
src/mt_odoo_mcp/confirmation/manager.py
Normal file
73
src/mt_odoo_mcp/confirmation/manager.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Per-user (non-singleton) delete confirmation token store."""
|
||||
from __future__ import annotations
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingDelete:
|
||||
token: str
|
||||
instance: str
|
||||
model: str
|
||||
record_id: int
|
||||
record_name: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ConfirmationManager:
|
||||
"""In-memory store for delete confirmation tokens. Single-use, TTL-limited.
|
||||
One instance per user — no shared state between tenants.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._pending: dict[str, PendingDelete] = {}
|
||||
|
||||
def _purge_expired(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [t for t, p in self._pending.items() if p.expires_at <= now]
|
||||
for t in expired:
|
||||
del self._pending[t]
|
||||
|
||||
def create_token(self, instance: str, model: str, record_id: int, record_name: str) -> PendingDelete:
|
||||
token = f"del_{secrets.token_hex(8)}"
|
||||
ttl = settings.confirm_token_ttl
|
||||
pending = PendingDelete(
|
||||
token=token, instance=instance, model=model,
|
||||
record_id=record_id, record_name=record_name,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl),
|
||||
)
|
||||
with self._lock:
|
||||
self._purge_expired()
|
||||
self._pending[token] = pending
|
||||
return pending
|
||||
|
||||
def consume_token(self, token: str, model: str, record_id: int) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
self._purge_expired()
|
||||
pending = self._pending.get(token)
|
||||
if not pending:
|
||||
return False, "Invalid or expired confirmation token. Call request_delete_confirmation again."
|
||||
if pending.model != model or pending.record_id != record_id:
|
||||
return False, "Token does not match the requested model/record."
|
||||
del self._pending[token]
|
||||
return True, ""
|
||||
|
||||
def list_pending(self) -> list[dict]:
|
||||
with self._lock:
|
||||
self._purge_expired()
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
{
|
||||
"token": p.token,
|
||||
"model": p.model,
|
||||
"record_id": p.record_id,
|
||||
"record_name": p.record_name,
|
||||
"expires_in_seconds": int((p.expires_at - now).total_seconds()),
|
||||
}
|
||||
for p in self._pending.values()
|
||||
]
|
||||
45
src/mt_odoo_mcp/context.py
Normal file
45
src/mt_odoo_mcp/context.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Per-request context vars — each MCP session carries its own user state."""
|
||||
from __future__ import annotations
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client.connection import ConnectionManager
|
||||
from .confirmation.manager import ConfirmationManager
|
||||
|
||||
_user_id: ContextVar[int] = ContextVar("user_id")
|
||||
_user_email: ContextVar[str] = ContextVar("user_email")
|
||||
_conn_manager: ContextVar["ConnectionManager"] = ContextVar("conn_manager")
|
||||
_confirm_manager: ContextVar["ConfirmationManager"] = ContextVar("confirm_manager")
|
||||
|
||||
|
||||
def get_user_id() -> int:
|
||||
return _user_id.get()
|
||||
|
||||
|
||||
def get_user_email() -> str:
|
||||
return _user_email.get()
|
||||
|
||||
|
||||
def get_conn_manager() -> "ConnectionManager":
|
||||
return _conn_manager.get()
|
||||
|
||||
|
||||
def get_confirm_manager() -> "ConfirmationManager":
|
||||
return _confirm_manager.get()
|
||||
|
||||
|
||||
def set_user_context(user_id: int, user_email: str, conn_mgr, confirm_mgr):
|
||||
"""Set all context vars for the current MCP session. Returns tokens for reset."""
|
||||
t1 = _user_id.set(user_id)
|
||||
t2 = _user_email.set(user_email)
|
||||
t3 = _conn_manager.set(conn_mgr)
|
||||
t4 = _confirm_manager.set(confirm_mgr)
|
||||
return (t1, t2, t3, t4)
|
||||
|
||||
|
||||
def reset_user_context(tokens):
|
||||
_user_id.reset(tokens[0])
|
||||
_user_email.reset(tokens[1])
|
||||
_conn_manager.reset(tokens[2])
|
||||
_confirm_manager.reset(tokens[3])
|
||||
71
src/mt_odoo_mcp/database.py
Normal file
71
src/mt_odoo_mcp/database.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""SQLite database — users and their Odoo credentials."""
|
||||
from __future__ import annotations
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from .config import settings
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn: sqlite3.Connection | None = None
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
global _conn
|
||||
if _conn is None:
|
||||
path = Path(settings.database_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute("PRAGMA foreign_keys=ON")
|
||||
return _conn
|
||||
|
||||
|
||||
def init_db():
|
||||
with _lock:
|
||||
db = _get_conn()
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
api_key_hash TEXT NOT NULL,
|
||||
api_key_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS odoo_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
instance_name TEXT NOT NULL DEFAULT 'default',
|
||||
url TEXT NOT NULL,
|
||||
database_name TEXT NOT NULL,
|
||||
odoo_username TEXT NOT NULL,
|
||||
credential_enc TEXT NOT NULL,
|
||||
is_api_key INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, instance_name)
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def db_execute(sql: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||
with _lock:
|
||||
return _get_conn().execute(sql, params)
|
||||
|
||||
|
||||
def db_commit():
|
||||
with _lock:
|
||||
_get_conn().commit()
|
||||
|
||||
|
||||
def db_fetchone(sql: str, params: tuple = ()) -> sqlite3.Row | None:
|
||||
with _lock:
|
||||
return _get_conn().execute(sql, params).fetchone()
|
||||
|
||||
|
||||
def db_fetchall(sql: str, params: tuple = ()) -> list[sqlite3.Row]:
|
||||
with _lock:
|
||||
return _get_conn().execute(sql, params).fetchall()
|
||||
43
src/mt_odoo_mcp/registry.py
Normal file
43
src/mt_odoo_mcp/registry.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""In-memory tenant registry — one ConnectionManager and ConfirmationManager per user."""
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
from .client.connection import ConnectionManager
|
||||
from .confirmation.manager import ConfirmationManager
|
||||
from .auth.service import get_odoo_instance_configs
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn_managers: dict[int, ConnectionManager] = {}
|
||||
_confirm_managers: dict[int, ConfirmationManager] = {}
|
||||
|
||||
|
||||
def get_conn_manager(user_id: int) -> ConnectionManager:
|
||||
"""Return the user's ConnectionManager, creating it if needed."""
|
||||
with _lock:
|
||||
if user_id not in _conn_managers:
|
||||
configs = get_odoo_instance_configs(user_id)
|
||||
_conn_managers[user_id] = ConnectionManager(configs)
|
||||
return _conn_managers[user_id]
|
||||
|
||||
|
||||
def get_confirm_manager(user_id: int) -> ConfirmationManager:
|
||||
with _lock:
|
||||
if user_id not in _confirm_managers:
|
||||
_confirm_managers[user_id] = ConfirmationManager()
|
||||
return _confirm_managers[user_id]
|
||||
|
||||
|
||||
def refresh_conn_manager(user_id: int):
|
||||
"""Force-reload credentials from DB (call after user adds/updates credentials)."""
|
||||
with _lock:
|
||||
configs = get_odoo_instance_configs(user_id)
|
||||
if user_id in _conn_managers:
|
||||
_conn_managers[user_id].reload_configs(configs)
|
||||
else:
|
||||
_conn_managers[user_id] = ConnectionManager(configs)
|
||||
|
||||
|
||||
def evict_user(user_id: int):
|
||||
"""Remove all in-memory state for a user (e.g. after account deletion)."""
|
||||
with _lock:
|
||||
_conn_managers.pop(user_id, None)
|
||||
_confirm_managers.pop(user_id, None)
|
||||
669
src/mt_odoo_mcp/server.py
Normal file
669
src/mt_odoo_mcp/server.py
Normal file
@ -0,0 +1,669 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from mcp.server import Server
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from .config import settings
|
||||
from .database import init_db
|
||||
from .auth.service import get_user_by_api_key
|
||||
from .registry import get_conn_manager, get_confirm_manager
|
||||
from .context import set_user_context, reset_user_context
|
||||
from .api.routes import router as api_router
|
||||
|
||||
# Tool implementations
|
||||
from .tools import (
|
||||
a_connection as conn_tools,
|
||||
b_discovery as disc_tools,
|
||||
c_records as rec_tools,
|
||||
d_workflow as wf_tools,
|
||||
e_smart as smart_tools,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.mcp_log_level, logging.INFO),
|
||||
stream=sys.stderr,
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ─── MCP Server ───────────────────────────────────────────────────────────────
|
||||
|
||||
mcp_app = Server(settings.mcp_server_name)
|
||||
|
||||
TOOLS: list[tuple[str, str, Any, dict]] = [
|
||||
# ── A. Connection ──────────────────────────────────────────────────────────
|
||||
("list_instances", "List all your configured Odoo instances and their connection status.",
|
||||
conn_tools.list_instances, {}),
|
||||
|
||||
("connect_instance", "Connect and authenticate to a named Odoo instance.",
|
||||
conn_tools.connect_instance, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instance": {"type": "string", "description": "Instance name (default: 'default')", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("get_current_context", "Get current session context: instance info, company, logged-in user, Odoo version.",
|
||||
conn_tools.get_current_context, {
|
||||
"type": "object",
|
||||
"properties": {"instance": {"type": "string", "default": "default"}},
|
||||
}),
|
||||
|
||||
("switch_instance", "Switch the active Odoo instance.",
|
||||
conn_tools.switch_instance, {
|
||||
"type": "object",
|
||||
"properties": {"instance": {"type": "string", "description": "Name of the instance to switch to"}},
|
||||
"required": ["instance"],
|
||||
}),
|
||||
|
||||
("health_check", "Ping Odoo to verify the connection is alive.",
|
||||
conn_tools.health_check, {
|
||||
"type": "object",
|
||||
"properties": {"instance": {"type": "string", "default": "default"}},
|
||||
}),
|
||||
|
||||
# ── B. Discovery ───────────────────────────────────────────────────────────
|
||||
("list_models", "List all data models available in Odoo.",
|
||||
disc_tools.list_models, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("search_models", "Search Odoo models by name or technical name keyword.",
|
||||
disc_tools.search_models, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["keyword"],
|
||||
}),
|
||||
|
||||
("get_model_fields", "Get all fields of a model: name, type, label, required, readonly, and relations.",
|
||||
disc_tools.get_model_fields, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model"],
|
||||
}),
|
||||
|
||||
("get_selection_options", "Get all possible values for a Selection/dropdown field.",
|
||||
disc_tools.get_selection_options, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"field": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "field"],
|
||||
}),
|
||||
|
||||
("get_model_relations", "Get all relational fields (Many2one, One2many, Many2many) for a model.",
|
||||
disc_tools.get_model_relations, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model"],
|
||||
}),
|
||||
|
||||
("describe_record", "Get the full context of a single record: all field values with labels.",
|
||||
disc_tools.describe_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
# ── C. Records CRUD ────────────────────────────────────────────────────────
|
||||
("search_records", "Search records in any Odoo model.",
|
||||
rec_tools.search_records, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"domain": {"type": "array", "default": []},
|
||||
"fields": {"type": "array", "items": {"type": "string"}},
|
||||
"limit": {"type": "integer", "default": 80},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
"order": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model"],
|
||||
}),
|
||||
|
||||
("read_record", "Read a single record by ID.",
|
||||
rec_tools.read_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"fields": {"type": "array", "items": {"type": "string"}},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("read_records", "Read multiple records by their IDs.",
|
||||
rec_tools.read_records, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_ids": {"type": "array", "items": {"type": "integer"}},
|
||||
"fields": {"type": "array", "items": {"type": "string"}},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_ids"],
|
||||
}),
|
||||
|
||||
("count_records", "Count records matching a domain.",
|
||||
rec_tools.count_records, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"domain": {"type": "array", "default": []},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model"],
|
||||
}),
|
||||
|
||||
("create_record", "Create a new record in any Odoo model.",
|
||||
rec_tools.create_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"values": {"type": "object"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "values"],
|
||||
}),
|
||||
|
||||
("update_record", "Update fields on an existing record.",
|
||||
rec_tools.update_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"values": {"type": "object"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id", "values"],
|
||||
}),
|
||||
|
||||
("request_delete_confirmation", "STEP 1 OF DELETE: Get a confirmation token before deleting a record.",
|
||||
rec_tools.request_delete_confirmation, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("delete_record", "STEP 2 OF DELETE: Permanently delete a record. Requires confirmation_token.",
|
||||
rec_tools.delete_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"confirmation_token": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("archive_record", "Soft-delete a record (sets active=False).",
|
||||
rec_tools.archive_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("unarchive_record", "Restore an archived record.",
|
||||
rec_tools.unarchive_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("duplicate_record", "Copy a record, optionally overriding some fields.",
|
||||
rec_tools.duplicate_record, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"override_values": {"type": "object"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("get_related_records", "Follow a relation field to fetch linked records.",
|
||||
rec_tools.get_related_records, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"relation_field": {"type": "string"},
|
||||
"fields": {"type": "array", "items": {"type": "string"}},
|
||||
"limit": {"type": "integer", "default": 80},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id", "relation_field"],
|
||||
}),
|
||||
|
||||
("export_records", "Export records as JSON for review or reporting.",
|
||||
rec_tools.export_records, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"domain": {"type": "array", "default": []},
|
||||
"fields": {"type": "array", "items": {"type": "string"}},
|
||||
"limit": {"type": "integer", "default": 200},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model"],
|
||||
}),
|
||||
|
||||
# ── D. Workflow ────────────────────────────────────────────────────────────
|
||||
("list_record_actions", "List available server actions/buttons for a record.",
|
||||
wf_tools.list_record_actions, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("execute_record_action", "Execute a method/button action on a record.",
|
||||
wf_tools.execute_record_action, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"method": {"type": "string"},
|
||||
"args": {"type": "array"},
|
||||
"kwargs": {"type": "object"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id", "method"],
|
||||
}),
|
||||
|
||||
("confirm_document", "Confirm/validate a document (sale order, purchase order, invoice, picking).",
|
||||
wf_tools.confirm_document, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("cancel_document", "Cancel a document.",
|
||||
wf_tools.cancel_document, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("reset_to_draft", "Reset a document back to draft state.",
|
||||
wf_tools.reset_to_draft, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
("change_record_stage", "Move a record to a specific stage by stage ID.",
|
||||
wf_tools.change_record_stage, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"stage_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id", "stage_id"],
|
||||
}),
|
||||
|
||||
("log_note", "Add an internal note to a record's chatter.",
|
||||
wf_tools.log_note, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"note": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id", "note"],
|
||||
}),
|
||||
|
||||
("get_record_chatter", "Get message history and internal notes from a record's chatter.",
|
||||
wf_tools.get_record_chatter, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string"},
|
||||
"record_id": {"type": "integer"},
|
||||
"limit": {"type": "integer", "default": 20},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["model", "record_id"],
|
||||
}),
|
||||
|
||||
# ── E. Smart Business Tools ────────────────────────────────────────────────
|
||||
("list_employees", "List employees with filters by department, job title, or name.",
|
||||
smart_tools.list_employees, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"department": {"type": "string"},
|
||||
"job_title": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"active_only": {"type": "boolean", "default": True},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("get_employee_details", "Get full profile of an employee.",
|
||||
smart_tools.get_employee_details, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"employee_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["employee_id"],
|
||||
}),
|
||||
|
||||
("get_employee_workload", "Get tasks and projects currently assigned to an employee.",
|
||||
smart_tools.get_employee_workload, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"employee_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["employee_id"],
|
||||
}),
|
||||
|
||||
("list_departments", "List all departments with head of department.",
|
||||
smart_tools.list_departments, {
|
||||
"type": "object",
|
||||
"properties": {"instance": {"type": "string", "default": "default"}},
|
||||
}),
|
||||
|
||||
("list_job_positions", "List available job positions.",
|
||||
smart_tools.list_job_positions, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"department": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("list_projects", "List projects with optional filters.",
|
||||
smart_tools.list_projects, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"customer": {"type": "string"},
|
||||
"manager": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("get_project_details", "Get full project details: tasks grouped by stage, team members, dates.",
|
||||
smart_tools.get_project_details, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
}),
|
||||
|
||||
("create_project", "Create a new project in Odoo.",
|
||||
smart_tools.create_project, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"customer_id": {"type": "integer"},
|
||||
"manager_user_id": {"type": "integer"},
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}),
|
||||
|
||||
("list_tasks", "List project tasks with filters.",
|
||||
smart_tools.list_tasks, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "integer"},
|
||||
"assignee_user_id": {"type": "integer"},
|
||||
"stage": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"deadline_before": {"type": "string"},
|
||||
"include_done": {"type": "boolean", "default": False},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("create_task", "Create a task in a project.",
|
||||
smart_tools.create_task, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"project_id": {"type": "integer"},
|
||||
"assignee_user_ids": {"type": "array", "items": {"type": "integer"}},
|
||||
"description": {"type": "string"},
|
||||
"deadline": {"type": "string"},
|
||||
"priority": {"type": "string", "enum": ["normal", "high"], "default": "normal"},
|
||||
"stage_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["name", "project_id"],
|
||||
}),
|
||||
|
||||
("assign_task", "Assign or reassign a task to one or more employees.",
|
||||
smart_tools.assign_task, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "integer"},
|
||||
"user_ids": {"type": "array", "items": {"type": "integer"}},
|
||||
"replace": {"type": "boolean", "default": True},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["task_id", "user_ids"],
|
||||
}),
|
||||
|
||||
("update_task_stage", "Move a task to a different stage.",
|
||||
smart_tools.update_task_stage, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "integer"},
|
||||
"stage_id": {"type": "integer"},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
"required": ["task_id", "stage_id"],
|
||||
}),
|
||||
|
||||
("list_customers", "List customers/partners with filters.",
|
||||
smart_tools.list_customers, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
"is_company": {"type": "boolean"},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("list_sales_orders", "List sales orders with status and customer filters.",
|
||||
smart_tools.list_sales_orders, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer_id": {"type": "integer"},
|
||||
"status": {"type": "string", "enum": ["draft", "sent", "sale", "done", "cancel"]},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
|
||||
("list_invoices", "List invoices with customer, status, and type filters.",
|
||||
smart_tools.list_invoices, {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer_id": {"type": "integer"},
|
||||
"status": {"type": "string", "enum": ["draft", "posted", "cancel"]},
|
||||
"invoice_type": {"type": "string", "enum": ["out_invoice", "in_invoice", "out_refund", "in_refund"],
|
||||
"default": "out_invoice"},
|
||||
"limit": {"type": "integer", "default": 50},
|
||||
"instance": {"type": "string", "default": "default"},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
@mcp_app.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [Tool(name=name, description=desc, inputSchema=schema) for name, desc, _, schema in TOOLS]
|
||||
|
||||
|
||||
@mcp_app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
entry = next((t for t in TOOLS if t[0] == name), None)
|
||||
if not entry:
|
||||
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: '{name}'"}))]
|
||||
|
||||
_, _, fn, _ = entry
|
||||
instance = arguments.get("instance", "default")
|
||||
|
||||
# Auto-connect instance (skip for tools that explicitly handle connection errors)
|
||||
from .context import get_conn_manager as _gcm
|
||||
try:
|
||||
_gcm().ensure_connected(instance)
|
||||
except Exception as e:
|
||||
if name not in ("list_instances", "health_check", "connect_instance"):
|
||||
return [TextContent(type="text", text=json.dumps(
|
||||
{"error": f"Cannot connect to Odoo instance '{instance}': {e}. "
|
||||
f"Add your credentials via POST /api/credentials."}))]
|
||||
|
||||
try:
|
||||
result = fn(**arguments)
|
||||
except TypeError as e:
|
||||
return [TextContent(type="text", text=json.dumps({"error": f"Invalid arguments: {e}"}))]
|
||||
except Exception as e:
|
||||
log.exception("Tool '%s' raised an error", name)
|
||||
return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
|
||||
|
||||
return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))]
|
||||
|
||||
|
||||
# ─── FastAPI App ──────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="Multi-Tenant Odoo MCP",
|
||||
description="Self-hosted MCP server for Odoo. Users register and manage their own Odoo instances.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Mount the REST API routes
|
||||
app.include_router(api_router)
|
||||
|
||||
# SSE transport — clients connect here
|
||||
sse_transport = SseServerTransport("/mcp/messages/")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "server": settings.mcp_server_name}
|
||||
|
||||
|
||||
@app.get("/mcp/sse")
|
||||
async def mcp_sse_endpoint(request: Request):
|
||||
"""MCP SSE connection endpoint. Authenticate with: ?api_key=<your_key> or Authorization header."""
|
||||
# Extract API key from query param or Authorization header
|
||||
api_key = request.query_params.get("api_key")
|
||||
if not api_key:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
api_key = auth_header.removeprefix("Bearer ").strip()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="API key required. Pass ?api_key=<key> or Authorization: Bearer <key>",
|
||||
)
|
||||
|
||||
user = get_user_by_api_key(api_key)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key.")
|
||||
|
||||
log.info("MCP SSE connection: user=%s (id=%d)", user.email, user.id)
|
||||
|
||||
# Build per-user managers
|
||||
conn_mgr = get_conn_manager(user.id)
|
||||
confirm_mgr = get_confirm_manager(user.id)
|
||||
|
||||
# Set context vars for this async task (propagates to all child tasks)
|
||||
tokens = set_user_context(user.id, user.email, conn_mgr, confirm_mgr)
|
||||
try:
|
||||
async with sse_transport.connect_sse(
|
||||
request.scope, request.receive, request._send
|
||||
) as (read_stream, write_stream):
|
||||
await mcp_app.run(read_stream, write_stream, mcp_app.create_initialization_options())
|
||||
finally:
|
||||
reset_user_context(tokens)
|
||||
|
||||
|
||||
@app.post("/mcp/messages/")
|
||||
async def mcp_messages_endpoint(request: Request):
|
||||
"""MCP message channel (used internally by the SSE transport)."""
|
||||
return await sse_transport.handle_post_message(request.scope, request.receive, request._send)
|
||||
|
||||
|
||||
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
init_db()
|
||||
log.info("Starting %s on %s:%d", settings.mcp_server_name, settings.host, settings.port)
|
||||
uvicorn.run(app, host=settings.host, port=settings.port, log_level=settings.mcp_log_level.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/mt_odoo_mcp/tools/__init__.py
Normal file
0
src/mt_odoo_mcp/tools/__init__.py
Normal file
80
src/mt_odoo_mcp/tools/a_connection.py
Normal file
80
src/mt_odoo_mcp/tools/a_connection.py
Normal file
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
from ..context import get_conn_manager, get_user_id, get_user_email
|
||||
|
||||
|
||||
def list_instances() -> dict:
|
||||
instances = get_conn_manager().list_instances()
|
||||
return {
|
||||
"instances": instances,
|
||||
"total": len(instances),
|
||||
"default_instance": instances[0]["name"] if instances else None,
|
||||
}
|
||||
|
||||
|
||||
def connect_instance(instance: str = "default") -> dict:
|
||||
try:
|
||||
session = get_conn_manager().ensure_connected(instance)
|
||||
return {
|
||||
"success": True,
|
||||
"instance": session.name,
|
||||
"url": session.url,
|
||||
"database": session.db,
|
||||
"username": session.username,
|
||||
"uid": session.uid,
|
||||
"odoo_version": session.version,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_current_context(instance: str = "default") -> dict:
|
||||
try:
|
||||
mgr = get_conn_manager()
|
||||
session = mgr.ensure_connected(instance)
|
||||
rpc = session.rpc
|
||||
|
||||
user = rpc.search_read(
|
||||
"res.users",
|
||||
[["id", "=", session.uid]],
|
||||
["name", "login", "company_id"],
|
||||
limit=1,
|
||||
)
|
||||
user_data = user[0] if user else {}
|
||||
company_id = user_data.get("company_id", [None, ""])[0] if user_data.get("company_id") else None
|
||||
company_name = user_data.get("company_id", [None, ""])[1] if user_data.get("company_id") else None
|
||||
|
||||
return {
|
||||
"mcp_user_email": get_user_email(),
|
||||
"instance": instance,
|
||||
"url": session.url,
|
||||
"database": session.db,
|
||||
"odoo_version": session.version,
|
||||
"odoo_user": {
|
||||
"id": session.uid,
|
||||
"name": user_data.get("name"),
|
||||
"login": user_data.get("login"),
|
||||
},
|
||||
"company": {"id": company_id, "name": company_name},
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def switch_instance(instance: str) -> dict:
|
||||
return connect_instance(instance)
|
||||
|
||||
|
||||
def health_check(instance: str = "default") -> dict:
|
||||
try:
|
||||
session = get_conn_manager().ensure_connected(instance)
|
||||
count = session.rpc.count("res.users", [["active", "=", True]])
|
||||
return {
|
||||
"healthy": True,
|
||||
"instance": instance,
|
||||
"url": session.url,
|
||||
"database": session.db,
|
||||
"odoo_version": session.version,
|
||||
"active_users": count,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "instance": instance, "error": str(e)}
|
||||
121
src/mt_odoo_mcp/tools/b_discovery.py
Normal file
121
src/mt_odoo_mcp/tools/b_discovery.py
Normal file
@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
from ..context import get_conn_manager
|
||||
|
||||
|
||||
def list_models(keyword: str = "", instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = []
|
||||
if keyword:
|
||||
domain = ["|", ["model", "ilike", keyword], ["name", "ilike", keyword]]
|
||||
models = rpc.search_read(
|
||||
"ir.model", domain, ["model", "name", "transient"],
|
||||
limit=200, order="model asc",
|
||||
)
|
||||
return {
|
||||
"models": [{"model": m["model"], "label": m["name"], "transient": m["transient"]} for m in models],
|
||||
"total": len(models),
|
||||
"keyword": keyword or None,
|
||||
}
|
||||
|
||||
|
||||
def search_models(keyword: str, instance: str = "default") -> dict:
|
||||
return list_models(keyword=keyword, instance=instance)
|
||||
|
||||
|
||||
def get_model_fields(model: str, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
raw = rpc.fields_get(model, ["string", "type", "required", "readonly", "relation",
|
||||
"selection", "help", "store", "compute"])
|
||||
except Exception as e:
|
||||
return {"error": f"Cannot get fields for '{model}': {e}"}
|
||||
|
||||
fields = []
|
||||
for fname, fdata in raw.items():
|
||||
entry: dict = {
|
||||
"name": fname,
|
||||
"label": fdata.get("string", fname),
|
||||
"type": fdata.get("type"),
|
||||
"required": fdata.get("required", False),
|
||||
"readonly": fdata.get("readonly", False),
|
||||
"stored": fdata.get("store", True),
|
||||
"computed": bool(fdata.get("compute")),
|
||||
}
|
||||
if fdata.get("relation"):
|
||||
entry["relation"] = fdata["relation"]
|
||||
if fdata.get("selection"):
|
||||
entry["selection"] = fdata["selection"]
|
||||
if fdata.get("help"):
|
||||
entry["help"] = fdata["help"]
|
||||
fields.append(entry)
|
||||
|
||||
fields.sort(key=lambda f: f["name"])
|
||||
return {"model": model, "fields": fields, "total": len(fields)}
|
||||
|
||||
|
||||
def get_selection_options(model: str, field: str, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
raw = rpc.fields_get(model, ["string", "type", "selection"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
fdata = raw.get(field)
|
||||
if not fdata:
|
||||
return {"error": f"Field '{field}' not found on model '{model}'"}
|
||||
if fdata.get("type") != "selection":
|
||||
return {"error": f"Field '{field}' is type '{fdata.get('type')}', not 'selection'"}
|
||||
|
||||
options = [{"value": v, "label": l} for v, l in (fdata.get("selection") or [])]
|
||||
return {"model": model, "field": field, "options": options}
|
||||
|
||||
|
||||
def get_model_relations(model: str, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
raw = rpc.fields_get(model, ["string", "type", "relation", "relation_field"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
relation_types = {"many2one", "one2many", "many2many"}
|
||||
relations = [
|
||||
{
|
||||
"field": fname,
|
||||
"label": fdata.get("string", fname),
|
||||
"type": fdata["type"],
|
||||
"related_model": fdata.get("relation"),
|
||||
"inverse_field": fdata.get("relation_field"),
|
||||
}
|
||||
for fname, fdata in raw.items()
|
||||
if fdata.get("type") in relation_types and fdata.get("relation")
|
||||
]
|
||||
relations.sort(key=lambda r: r["field"])
|
||||
return {"model": model, "relations": relations, "total": len(relations)}
|
||||
|
||||
|
||||
def describe_record(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
raw_fields = rpc.fields_get(model, ["string", "type", "relation", "store"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
stored_fields = [
|
||||
fname for fname, fd in raw_fields.items()
|
||||
if fd.get("store", True) and fd.get("type") not in ("one2many", "many2many")
|
||||
]
|
||||
records = rpc.read(model, [record_id], stored_fields)
|
||||
if not records:
|
||||
return {"error": f"Record {record_id} not found in '{model}'"}
|
||||
|
||||
record = records[0]
|
||||
readable: dict = {}
|
||||
for fname, value in record.items():
|
||||
ftype = raw_fields.get(fname, {}).get("type", "")
|
||||
label = raw_fields.get(fname, {}).get("string", fname)
|
||||
if ftype == "many2one" and isinstance(value, list):
|
||||
readable[fname] = {"label": label, "id": value[0], "name": value[1]}
|
||||
else:
|
||||
readable[fname] = {"label": label, "value": value}
|
||||
|
||||
return {"model": model, "record_id": record_id, "fields": readable}
|
||||
259
src/mt_odoo_mcp/tools/c_records.py
Normal file
259
src/mt_odoo_mcp/tools/c_records.py
Normal file
@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from ..context import get_conn_manager, get_confirm_manager, get_user_id, get_user_email
|
||||
from ..audit.logger import audit_logger
|
||||
|
||||
|
||||
def _name_of(rpc, model: str, record_id: int) -> str:
|
||||
try:
|
||||
rows = rpc.read(model, [record_id], ["name", "display_name"])
|
||||
if rows:
|
||||
return rows[0].get("display_name") or rows[0].get("name") or f"#{record_id}"
|
||||
except Exception:
|
||||
pass
|
||||
return f"#{record_id}"
|
||||
|
||||
|
||||
def _audit(tool, params, summary, instance, success=True, error=None):
|
||||
audit_logger.log(tool, params, summary, instance,
|
||||
user_id=get_user_id(), user_email=get_user_email(),
|
||||
success=success, error=error)
|
||||
|
||||
|
||||
def search_records(
|
||||
model: str, domain: Optional[list] = None, fields: Optional[list[str]] = None,
|
||||
limit: int = 80, offset: int = 0, order: str = "", instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain = domain or []
|
||||
fields = fields or ["id", "display_name"]
|
||||
limit = min(limit, 500)
|
||||
try:
|
||||
records = rpc.search_read(model, domain, fields, limit=limit, offset=offset, order=order)
|
||||
total = rpc.count(model, domain)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
_audit("search_records", {"model": model, "domain": domain, "limit": limit}, f"{len(records)} records", instance)
|
||||
return {
|
||||
"model": model, "records": records, "count": len(records),
|
||||
"total_matching": total, "offset": offset,
|
||||
"has_more": (offset + len(records)) < total,
|
||||
}
|
||||
|
||||
|
||||
def read_record(model: str, record_id: int, fields: Optional[list[str]] = None, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
fields = fields or []
|
||||
try:
|
||||
rows = rpc.read(model, [record_id], fields)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if not rows:
|
||||
return {"error": f"Record {record_id} not found in '{model}'"}
|
||||
_audit("read_record", {"model": model, "record_id": record_id}, "read ok", instance)
|
||||
return {"model": model, "record": rows[0]}
|
||||
|
||||
|
||||
def read_records(model: str, record_ids: list[int], fields: Optional[list[str]] = None, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
fields = fields or []
|
||||
try:
|
||||
rows = rpc.read(model, record_ids, fields)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("read_records", {"model": model, "record_ids": record_ids}, f"{len(rows)} records", instance)
|
||||
return {"model": model, "records": rows, "count": len(rows)}
|
||||
|
||||
|
||||
def count_records(model: str, domain: Optional[list] = None, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain = domain or []
|
||||
try:
|
||||
total = rpc.count(model, domain)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"model": model, "domain": domain, "count": total}
|
||||
|
||||
|
||||
def create_record(model: str, values: dict, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
new_id = rpc.create(model, values)
|
||||
except Exception as e:
|
||||
_audit("create_record", {"model": model, "values": values}, "", instance, success=False, error=str(e))
|
||||
return {"error": str(e)}
|
||||
name = _name_of(rpc, model, new_id)
|
||||
_audit("create_record", {"model": model, "values": values}, f"Created '{name}' (id={new_id})", instance)
|
||||
return {"success": True, "model": model, "record_id": new_id, "record_name": name,
|
||||
"message": f"Created '{name}' with id={new_id}"}
|
||||
|
||||
|
||||
def update_record(model: str, record_id: int, values: dict, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
name = _name_of(rpc, model, record_id)
|
||||
try:
|
||||
rpc.write(model, [record_id], values)
|
||||
except Exception as e:
|
||||
_audit("update_record", {"model": model, "record_id": record_id, "values": values}, "", instance, success=False, error=str(e))
|
||||
return {"error": str(e)}
|
||||
_audit("update_record", {"model": model, "record_id": record_id, "values": values}, f"Updated '{name}'", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "record_name": name,
|
||||
"updated_fields": list(values.keys()), "message": f"Updated '{name}' (id={record_id})"}
|
||||
|
||||
|
||||
def request_delete_confirmation(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
record_name = _name_of(rpc, model, record_id)
|
||||
try:
|
||||
rows = rpc.read(model, [record_id], [])
|
||||
if not rows:
|
||||
return {"error": f"Record {record_id} not found in '{model}'"}
|
||||
except Exception as e:
|
||||
return {"error": f"Cannot read record: {e}"}
|
||||
|
||||
pending = get_confirm_manager().create_token(instance, model, record_id, record_name)
|
||||
import time
|
||||
ttl = int(pending.expires_at.timestamp() - time.time())
|
||||
return {
|
||||
"action_required": "CONFIRM_WITH_USER",
|
||||
"warning": "⚠️ This will permanently delete the record. This cannot be undone.",
|
||||
"record_to_delete": {"model": model, "record_id": record_id, "record_name": record_name},
|
||||
"confirmation_token": pending.token,
|
||||
"token_valid_for_seconds": ttl,
|
||||
"instructions": (
|
||||
f"Present this to the user: 'You are about to permanently delete "
|
||||
f"**{record_name}** (id={record_id}) from {model}. Are you sure?' "
|
||||
f"Only call delete_record with this token if the user explicitly confirms."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def delete_record(
|
||||
model: str, record_id: int,
|
||||
confirmation_token: Optional[str] = None,
|
||||
instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
if not confirmation_token:
|
||||
record_name = _name_of(rpc, model, record_id)
|
||||
return {
|
||||
"error": "delete_requires_confirmation",
|
||||
"message": (
|
||||
"Delete requires explicit user confirmation. "
|
||||
"Call request_delete_confirmation first, present the details to the user, "
|
||||
"then call delete_record with the confirmation_token."
|
||||
),
|
||||
"record": {"model": model, "record_id": record_id, "record_name": record_name},
|
||||
"next_step": "call request_delete_confirmation(model, record_id)",
|
||||
}
|
||||
|
||||
ok, err = get_confirm_manager().consume_token(confirmation_token, model, record_id)
|
||||
if not ok:
|
||||
return {"error": err}
|
||||
|
||||
record_name = _name_of(rpc, model, record_id)
|
||||
try:
|
||||
rpc.unlink(model, [record_id])
|
||||
except Exception as e:
|
||||
_audit("delete_record", {"model": model, "record_id": record_id}, "", instance, success=False, error=str(e))
|
||||
return {"error": f"Delete failed: {e}"}
|
||||
|
||||
_audit("delete_record", {"model": model, "record_id": record_id, "record_name": record_name},
|
||||
f"Deleted '{record_name}'", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "record_name": record_name,
|
||||
"message": f"Permanently deleted '{record_name}' (id={record_id}) from {model}."}
|
||||
|
||||
|
||||
def archive_record(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
name = _name_of(rpc, model, record_id)
|
||||
try:
|
||||
rpc.write(model, [record_id], {"active": False})
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("archive_record", {"model": model, "record_id": record_id}, f"Archived '{name}'", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "record_name": name,
|
||||
"message": f"Archived '{name}'. Record is hidden but can be restored with unarchive_record."}
|
||||
|
||||
|
||||
def unarchive_record(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
name = _name_of(rpc, model, record_id)
|
||||
try:
|
||||
rpc.write(model, [record_id], {"active": True})
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("unarchive_record", {"model": model, "record_id": record_id}, f"Unarchived '{name}'", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "record_name": name,
|
||||
"message": f"Restored '{name}' — record is now active."}
|
||||
|
||||
|
||||
def duplicate_record(model: str, record_id: int, override_values: Optional[dict] = None, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
name = _name_of(rpc, model, record_id)
|
||||
try:
|
||||
new_id = rpc.execute(model, "copy", record_id, override_values or {})
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
new_name = _name_of(rpc, model, new_id)
|
||||
_audit("duplicate_record", {"model": model, "record_id": record_id}, f"Duplicated as id={new_id}", instance)
|
||||
return {"success": True, "original": {"id": record_id, "name": name},
|
||||
"copy": {"id": new_id, "name": new_name},
|
||||
"message": f"Duplicated '{name}' → new record '{new_name}' (id={new_id})"}
|
||||
|
||||
|
||||
def get_related_records(
|
||||
model: str, record_id: int, relation_field: str,
|
||||
fields: Optional[list[str]] = None, limit: int = 80, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
fields = fields or ["id", "display_name"]
|
||||
try:
|
||||
parent = rpc.read(model, [record_id], [relation_field])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if not parent:
|
||||
return {"error": f"Record {record_id} not found in '{model}'"}
|
||||
|
||||
value = parent[0].get(relation_field)
|
||||
if value is None:
|
||||
return {"error": f"Field '{relation_field}' not found on '{model}'"}
|
||||
|
||||
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], str):
|
||||
rel_id = value[0]
|
||||
rel_fields_raw = rpc.fields_get(model, ["relation"])
|
||||
related_model = rel_fields_raw.get(relation_field, {}).get("relation", "unknown")
|
||||
rows = rpc.read(related_model, [rel_id], fields)
|
||||
return {"relation_type": "many2one", "parent_model": model, "parent_id": record_id,
|
||||
"related_model": related_model, "records": rows}
|
||||
|
||||
if isinstance(value, list):
|
||||
rel_fields_raw = rpc.fields_get(model, ["relation"])
|
||||
related_model = rel_fields_raw.get(relation_field, {}).get("relation", "unknown")
|
||||
ids = value[:limit]
|
||||
rows = rpc.read(related_model, ids, fields) if ids else []
|
||||
return {"relation_type": "one2many_or_many2many", "parent_model": model, "parent_id": record_id,
|
||||
"related_model": related_model, "records": rows,
|
||||
"total_ids": len(value), "returned": len(rows)}
|
||||
|
||||
return {"error": f"Unexpected value type for field '{relation_field}': {type(value).__name__}"}
|
||||
|
||||
|
||||
def export_records(
|
||||
model: str, domain: Optional[list] = None,
|
||||
fields: Optional[list[str]] = None, limit: int = 200, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain = domain or []
|
||||
fields = fields or ["id", "display_name"]
|
||||
limit = min(limit, 1000)
|
||||
try:
|
||||
records = rpc.search_read(model, domain, fields, limit=limit)
|
||||
total = rpc.count(model, domain)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("export_records", {"model": model, "domain": domain, "limit": limit},
|
||||
f"Exported {len(records)} records", instance)
|
||||
return {"model": model, "exported": len(records), "total_matching": total,
|
||||
"fields": fields, "records": records}
|
||||
154
src/mt_odoo_mcp/tools/d_workflow.py
Normal file
154
src/mt_odoo_mcp/tools/d_workflow.py
Normal file
@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from typing import Optional
|
||||
from ..context import get_conn_manager, get_user_id, get_user_email
|
||||
from ..audit.logger import audit_logger
|
||||
|
||||
|
||||
def _audit(tool, params, summary, instance, success=True, error=None):
|
||||
audit_logger.log(tool, params, summary, instance,
|
||||
user_id=get_user_id(), user_email=get_user_email(),
|
||||
success=success, error=error)
|
||||
|
||||
|
||||
def list_record_actions(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
actions = rpc.search_read(
|
||||
"ir.actions.server",
|
||||
[["model_name", "=", model], ["binding_type", "in", ["action", "report"]]],
|
||||
["name", "binding_type", "state", "groups_id"],
|
||||
limit=50,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"model": model, "record_id": record_id,
|
||||
"available_actions": [{"name": a["name"], "type": a["binding_type"], "state": a["state"]} for a in actions],
|
||||
"total": len(actions),
|
||||
}
|
||||
|
||||
|
||||
def execute_record_action(
|
||||
model: str, record_id: int, method: str,
|
||||
args: Optional[list] = None, kwargs: Optional[dict] = None,
|
||||
instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
result = rpc.execute(model, method, [record_id], *(args or []), **(kwargs or {}))
|
||||
except Exception as e:
|
||||
_audit("execute_record_action", {"model": model, "record_id": record_id, "method": method}, "", instance, success=False, error=str(e))
|
||||
return {"error": str(e)}
|
||||
_audit("execute_record_action", {"model": model, "record_id": record_id, "method": method}, f"Executed '{method}'", instance)
|
||||
return {
|
||||
"success": True, "model": model, "record_id": record_id, "method": method,
|
||||
"result": result if not isinstance(result, dict) or len(str(result)) < 2000 else "Action returned a wizard/view (success)",
|
||||
}
|
||||
|
||||
|
||||
def confirm_document(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
confirm_methods = {
|
||||
"sale.order": "action_confirm",
|
||||
"purchase.order": "button_confirm",
|
||||
"account.move": "action_post",
|
||||
"stock.picking": "button_validate",
|
||||
"mrp.production": "button_mark_done",
|
||||
}
|
||||
method = confirm_methods.get(model, "action_confirm")
|
||||
try:
|
||||
rpc.execute(model, method, [record_id])
|
||||
except Exception as e:
|
||||
_audit("confirm_document", {"model": model, "record_id": record_id, "method": method}, "", instance, success=False, error=str(e))
|
||||
return {"error": str(e), "tried_method": method}
|
||||
_audit("confirm_document", {"model": model, "record_id": record_id, "method": method}, f"Confirmed via {method}", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "method_used": method,
|
||||
"message": "Document confirmed/validated successfully."}
|
||||
|
||||
|
||||
def cancel_document(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
cancel_methods = {
|
||||
"sale.order": "action_cancel",
|
||||
"purchase.order": "button_cancel",
|
||||
"account.move": "button_cancel",
|
||||
"stock.picking": "action_cancel",
|
||||
}
|
||||
method = cancel_methods.get(model, "action_cancel")
|
||||
try:
|
||||
rpc.execute(model, method, [record_id])
|
||||
except Exception as e:
|
||||
_audit("cancel_document", {"model": model, "record_id": record_id}, "", instance, success=False, error=str(e))
|
||||
return {"error": str(e), "tried_method": method}
|
||||
_audit("cancel_document", {"model": model, "record_id": record_id}, f"Cancelled via {method}", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "method_used": method,
|
||||
"message": "Document cancelled successfully."}
|
||||
|
||||
|
||||
def reset_to_draft(model: str, record_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
draft_methods = {
|
||||
"sale.order": "action_draft",
|
||||
"account.move": "button_draft",
|
||||
"stock.picking": "action_draft",
|
||||
}
|
||||
method = draft_methods.get(model, "action_draft")
|
||||
try:
|
||||
rpc.execute(model, method, [record_id])
|
||||
except Exception as e:
|
||||
return {"error": str(e), "tried_method": method}
|
||||
_audit("reset_to_draft", {"model": model, "record_id": record_id}, f"Reset to draft via {method}", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id, "message": "Document reset to draft."}
|
||||
|
||||
|
||||
def change_record_stage(model: str, record_id: int, stage_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
field = "stage_id"
|
||||
try:
|
||||
rpc.write(model, [record_id], {field: stage_id})
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("change_record_stage", {"model": model, "record_id": record_id, "stage_id": stage_id},
|
||||
f"Stage changed to {stage_id}", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id,
|
||||
"new_stage_id": stage_id, "message": f"Record moved to stage {stage_id}."}
|
||||
|
||||
|
||||
def log_note(model: str, record_id: int, note: str, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
msg_id = rpc.execute(model, "message_post", record_id,
|
||||
body=note, message_type="comment", subtype_xmlid="mail.mt_note")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("log_note", {"model": model, "record_id": record_id}, "Note added", instance)
|
||||
return {"success": True, "model": model, "record_id": record_id,
|
||||
"message_id": msg_id, "message": "Internal note added to record."}
|
||||
|
||||
|
||||
def get_record_chatter(model: str, record_id: int, limit: int = 20, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
messages = rpc.search_read(
|
||||
"mail.message",
|
||||
[["res_id", "=", record_id], ["model", "=", model]],
|
||||
["date", "author_id", "message_type", "subtype_id", "body", "subject"],
|
||||
limit=limit, order="date desc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
cleaned = []
|
||||
for m in messages:
|
||||
body = re.sub(r"<[^>]+>", "", m.get("body", "")).strip()
|
||||
cleaned.append({
|
||||
"date": m.get("date"),
|
||||
"author": m.get("author_id", [None, "Unknown"])[1],
|
||||
"type": m.get("message_type"),
|
||||
"subtype": m.get("subtype_id", [None, ""])[1] if m.get("subtype_id") else None,
|
||||
"subject": m.get("subject"),
|
||||
"body": body[:500],
|
||||
})
|
||||
|
||||
return {"model": model, "record_id": record_id, "messages": cleaned, "total_returned": len(cleaned)}
|
||||
487
src/mt_odoo_mcp/tools/e_smart.py
Normal file
487
src/mt_odoo_mcp/tools/e_smart.py
Normal file
@ -0,0 +1,487 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from ..context import get_conn_manager, get_user_id, get_user_email
|
||||
from ..audit.logger import audit_logger
|
||||
|
||||
|
||||
def _audit(tool, params, summary, instance, success=True, error=None):
|
||||
audit_logger.log(tool, params, summary, instance,
|
||||
user_id=get_user_id(), user_email=get_user_email(),
|
||||
success=success, error=error)
|
||||
|
||||
|
||||
# ─── Employees ────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_employees(
|
||||
department: Optional[str] = None, job_title: Optional[str] = None,
|
||||
name: Optional[str] = None, active_only: bool = True, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = []
|
||||
if active_only:
|
||||
domain.append(["active", "=", True])
|
||||
if department:
|
||||
domain.append(["department_id.name", "ilike", department])
|
||||
if job_title:
|
||||
domain.append(["job_title", "ilike", job_title])
|
||||
if name:
|
||||
domain.append(["name", "ilike", name])
|
||||
try:
|
||||
employees = rpc.search_read(
|
||||
"hr.employee", domain,
|
||||
["id", "name", "job_title", "job_id", "department_id", "work_email", "user_id", "parent_id"],
|
||||
limit=200, order="department_id asc, name asc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
result = [
|
||||
{
|
||||
"id": emp["id"], "name": emp["name"],
|
||||
"job_title": emp.get("job_title"),
|
||||
"job_position": emp["job_id"][1] if emp.get("job_id") else None,
|
||||
"department": emp["department_id"][1] if emp.get("department_id") else None,
|
||||
"department_id": emp["department_id"][0] if emp.get("department_id") else None,
|
||||
"email": emp.get("work_email"),
|
||||
"odoo_user_id": emp["user_id"][0] if emp.get("user_id") else None,
|
||||
"odoo_username": emp["user_id"][1] if emp.get("user_id") else None,
|
||||
"manager": emp["parent_id"][1] if emp.get("parent_id") else None,
|
||||
}
|
||||
for emp in employees
|
||||
]
|
||||
return {"employees": result, "total": len(result),
|
||||
"filters": {"department": department, "job_title": job_title, "name": name}}
|
||||
|
||||
|
||||
def get_employee_details(employee_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
rows = rpc.read("hr.employee", [employee_id],
|
||||
["name", "job_title", "job_id", "department_id", "parent_id",
|
||||
"work_email", "work_phone", "mobile_phone", "user_id", "child_ids", "coach_id"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if not rows:
|
||||
return {"error": f"Employee {employee_id} not found"}
|
||||
emp = rows[0]
|
||||
return {
|
||||
"id": emp["id"], "name": emp["name"],
|
||||
"job_title": emp.get("job_title"),
|
||||
"job_position": emp["job_id"][1] if emp.get("job_id") else None,
|
||||
"department": emp["department_id"][1] if emp.get("department_id") else None,
|
||||
"manager": emp["parent_id"][1] if emp.get("parent_id") else None,
|
||||
"coach": emp["coach_id"][1] if emp.get("coach_id") else None,
|
||||
"work_email": emp.get("work_email"),
|
||||
"work_phone": emp.get("work_phone"),
|
||||
"mobile_phone": emp.get("mobile_phone"),
|
||||
"odoo_user_id": emp["user_id"][0] if emp.get("user_id") else None,
|
||||
"odoo_login": emp["user_id"][1] if emp.get("user_id") else None,
|
||||
"subordinates_count": len(emp.get("child_ids", [])),
|
||||
}
|
||||
|
||||
|
||||
def get_employee_workload(employee_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
emp_rows = rpc.read("hr.employee", [employee_id], ["name", "user_id"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if not emp_rows:
|
||||
return {"error": f"Employee {employee_id} not found"}
|
||||
emp = emp_rows[0]
|
||||
emp_name = emp["name"]
|
||||
user_id = emp["user_id"][0] if emp.get("user_id") else None
|
||||
if not user_id:
|
||||
return {"employee": emp_name, "warning": "Employee has no linked Odoo user account.",
|
||||
"tasks": [], "projects": []}
|
||||
try:
|
||||
tasks = rpc.search_read(
|
||||
"project.task",
|
||||
[["user_ids", "in", [user_id]], ["stage_id.fold", "!=", True]],
|
||||
["id", "name", "project_id", "stage_id", "date_deadline", "priority"],
|
||||
limit=50, order="date_deadline asc",
|
||||
)
|
||||
projects = rpc.search_read(
|
||||
"project.project",
|
||||
[["user_id", "=", user_id], ["active", "=", True]],
|
||||
["id", "name", "partner_id", "date_start", "date"],
|
||||
limit=20,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"employee": emp_name, "employee_id": employee_id, "odoo_user_id": user_id,
|
||||
"assigned_tasks": [
|
||||
{"id": t["id"], "name": t["name"],
|
||||
"project": t["project_id"][1] if t.get("project_id") else None,
|
||||
"stage": t["stage_id"][1] if t.get("stage_id") else None,
|
||||
"deadline": t.get("date_deadline"), "priority": t.get("priority")}
|
||||
for t in tasks
|
||||
],
|
||||
"managed_projects": [
|
||||
{"id": p["id"], "name": p["name"],
|
||||
"customer": p["partner_id"][1] if p.get("partner_id") else None,
|
||||
"start_date": p.get("date_start"), "end_date": p.get("date")}
|
||||
for p in projects
|
||||
],
|
||||
"open_task_count": len(tasks),
|
||||
}
|
||||
|
||||
|
||||
def list_departments(instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
depts = rpc.search_read("hr.department", [], ["id", "name", "manager_id", "parent_id"],
|
||||
limit=100, order="name asc")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"departments": [
|
||||
{"id": d["id"], "name": d["name"],
|
||||
"manager": d["manager_id"][1] if d.get("manager_id") else None,
|
||||
"manager_id": d["manager_id"][0] if d.get("manager_id") else None,
|
||||
"parent_department": d["parent_id"][1] if d.get("parent_id") else None}
|
||||
for d in depts
|
||||
],
|
||||
"total": len(depts),
|
||||
}
|
||||
|
||||
|
||||
def list_job_positions(department: Optional[str] = None, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = []
|
||||
if department:
|
||||
domain.append(["department_id.name", "ilike", department])
|
||||
try:
|
||||
positions = rpc.search_read("hr.job", domain,
|
||||
["id", "name", "department_id", "no_of_employee", "no_of_recruitment"],
|
||||
limit=100, order="name asc")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"job_positions": [
|
||||
{"id": p["id"], "title": p["name"],
|
||||
"department": p["department_id"][1] if p.get("department_id") else None,
|
||||
"current_employees": p.get("no_of_employee", 0),
|
||||
"open_recruitments": p.get("no_of_recruitment", 0)}
|
||||
for p in positions
|
||||
],
|
||||
"total": len(positions),
|
||||
}
|
||||
|
||||
|
||||
# ─── Projects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_projects(
|
||||
name: Optional[str] = None, customer: Optional[str] = None,
|
||||
manager: Optional[str] = None, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = [["active", "=", True]]
|
||||
if customer:
|
||||
domain.append(["partner_id.name", "ilike", customer])
|
||||
if manager:
|
||||
domain.append(["user_id.name", "ilike", manager])
|
||||
if name:
|
||||
domain.append(["name", "ilike", name])
|
||||
try:
|
||||
projects = rpc.search_read(
|
||||
"project.project", domain,
|
||||
["id", "name", "partner_id", "user_id", "date_start", "date", "task_count"],
|
||||
limit=100, order="name asc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"projects": [
|
||||
{"id": p["id"], "name": p["name"],
|
||||
"customer": p["partner_id"][1] if p.get("partner_id") else None,
|
||||
"customer_id": p["partner_id"][0] if p.get("partner_id") else None,
|
||||
"manager": p["user_id"][1] if p.get("user_id") else None,
|
||||
"manager_id": p["user_id"][0] if p.get("user_id") else None,
|
||||
"start_date": p.get("date_start"), "end_date": p.get("date"),
|
||||
"task_count": p.get("task_count", 0)}
|
||||
for p in projects
|
||||
],
|
||||
"total": len(projects),
|
||||
}
|
||||
|
||||
|
||||
def get_project_details(project_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
rows = rpc.read("project.project", [project_id],
|
||||
["name", "partner_id", "user_id", "date_start", "date", "description", "task_count"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
if not rows:
|
||||
return {"error": f"Project {project_id} not found"}
|
||||
p = rows[0]
|
||||
try:
|
||||
tasks = rpc.search_read(
|
||||
"project.task", [["project_id", "=", project_id]],
|
||||
["id", "name", "stage_id", "user_ids", "date_deadline", "priority"],
|
||||
limit=200, order="stage_id asc, name asc",
|
||||
)
|
||||
except Exception:
|
||||
tasks = []
|
||||
try:
|
||||
stages = rpc.search_read(
|
||||
"project.task.type", [["project_ids", "in", [project_id]]],
|
||||
["id", "name", "sequence", "fold"], limit=20, order="sequence asc",
|
||||
)
|
||||
except Exception:
|
||||
stages = []
|
||||
tasks_by_stage: dict = {}
|
||||
for t in tasks:
|
||||
stage_name = t["stage_id"][1] if t.get("stage_id") else "No Stage"
|
||||
tasks_by_stage.setdefault(stage_name, []).append({
|
||||
"id": t["id"], "name": t["name"],
|
||||
"assignees": [u[1] for u in (t.get("user_ids") or []) if isinstance(u, list)],
|
||||
"deadline": t.get("date_deadline"), "priority": t.get("priority"),
|
||||
})
|
||||
return {
|
||||
"id": project_id, "name": p["name"],
|
||||
"customer": p["partner_id"][1] if p.get("partner_id") else None,
|
||||
"manager": p["user_id"][1] if p.get("user_id") else None,
|
||||
"start_date": p.get("date_start"), "end_date": p.get("date"),
|
||||
"description": p.get("description") or "",
|
||||
"total_tasks": p.get("task_count", 0),
|
||||
"stages": [s["name"] for s in stages],
|
||||
"tasks_by_stage": tasks_by_stage,
|
||||
}
|
||||
|
||||
|
||||
def create_project(
|
||||
name: str, customer_id: Optional[int] = None, manager_user_id: Optional[int] = None,
|
||||
start_date: Optional[str] = None, end_date: Optional[str] = None,
|
||||
description: Optional[str] = None, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
values: dict = {"name": name}
|
||||
if customer_id:
|
||||
values["partner_id"] = customer_id
|
||||
if manager_user_id:
|
||||
values["user_id"] = manager_user_id
|
||||
if start_date:
|
||||
values["date_start"] = start_date
|
||||
if end_date:
|
||||
values["date"] = end_date
|
||||
if description:
|
||||
values["description"] = description
|
||||
try:
|
||||
project_id = rpc.create("project.project", values)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
_audit("create_project", {"name": name}, f"Created project id={project_id}", instance)
|
||||
return {"success": True, "project_id": project_id, "name": name,
|
||||
"message": f"Project '{name}' created with id={project_id}"}
|
||||
|
||||
|
||||
# ─── Tasks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_tasks(
|
||||
project_id: Optional[int] = None, assignee_user_id: Optional[int] = None,
|
||||
stage: Optional[str] = None, name: Optional[str] = None,
|
||||
deadline_before: Optional[str] = None, include_done: bool = False,
|
||||
instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = []
|
||||
if project_id:
|
||||
domain.append(["project_id", "=", project_id])
|
||||
if assignee_user_id:
|
||||
domain.append(["user_ids", "in", [assignee_user_id]])
|
||||
if stage:
|
||||
domain.append(["stage_id.name", "ilike", stage])
|
||||
if name:
|
||||
domain.append(["name", "ilike", name])
|
||||
if deadline_before:
|
||||
domain.append(["date_deadline", "<=", deadline_before])
|
||||
if not include_done:
|
||||
domain.append(["stage_id.fold", "!=", True])
|
||||
try:
|
||||
tasks = rpc.search_read(
|
||||
"project.task", domain,
|
||||
["id", "name", "project_id", "stage_id", "user_ids", "date_deadline", "priority"],
|
||||
limit=200, order="date_deadline asc, name asc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"tasks": [
|
||||
{"id": t["id"], "name": t["name"],
|
||||
"project": t["project_id"][1] if t.get("project_id") else None,
|
||||
"project_id": t["project_id"][0] if t.get("project_id") else None,
|
||||
"stage": t["stage_id"][1] if t.get("stage_id") else None,
|
||||
"stage_id": t["stage_id"][0] if t.get("stage_id") else None,
|
||||
"assignees": [u[1] for u in (t.get("user_ids") or []) if isinstance(u, list)],
|
||||
"assignee_ids": [u[0] for u in (t.get("user_ids") or []) if isinstance(u, list)],
|
||||
"deadline": t.get("date_deadline"),
|
||||
"priority": "high" if t.get("priority") == "1" else "normal"}
|
||||
for t in tasks
|
||||
],
|
||||
"total": len(tasks),
|
||||
}
|
||||
|
||||
|
||||
def create_task(
|
||||
name: str, project_id: int,
|
||||
assignee_user_ids: Optional[list[int]] = None, description: Optional[str] = None,
|
||||
deadline: Optional[str] = None, priority: str = "normal",
|
||||
stage_id: Optional[int] = None, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
values: dict = {"name": name, "project_id": project_id,
|
||||
"priority": "1" if priority == "high" else "0"}
|
||||
if assignee_user_ids:
|
||||
values["user_ids"] = [(6, 0, assignee_user_ids)]
|
||||
if description:
|
||||
values["description"] = description
|
||||
if deadline:
|
||||
values["date_deadline"] = deadline
|
||||
if stage_id:
|
||||
values["stage_id"] = stage_id
|
||||
try:
|
||||
task_id = rpc.create("project.task", values)
|
||||
except Exception as e:
|
||||
_audit("create_task", {"name": name, "project_id": project_id}, "", instance, success=False, error=str(e))
|
||||
return {"error": str(e)}
|
||||
_audit("create_task", {"name": name, "project_id": project_id}, f"Created task id={task_id}", instance)
|
||||
return {"success": True, "task_id": task_id, "name": name, "project_id": project_id,
|
||||
"assignee_user_ids": assignee_user_ids or [],
|
||||
"message": f"Task '{name}' created (id={task_id}) in project {project_id}"}
|
||||
|
||||
|
||||
def assign_task(task_id: int, user_ids: list[int], replace: bool = True, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
values = {"user_ids": [(6, 0, user_ids)]} if replace else {"user_ids": [(4, uid) for uid in user_ids]}
|
||||
try:
|
||||
rpc.write("project.task", [task_id], values)
|
||||
task = rpc.read("project.task", [task_id], ["name", "project_id", "user_ids"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
task_data = task[0] if task else {}
|
||||
assignees = [u[1] for u in (task_data.get("user_ids") or []) if isinstance(u, list)]
|
||||
_audit("assign_task", {"task_id": task_id, "user_ids": user_ids},
|
||||
f"Assigned task '{task_data.get('name')}'", instance)
|
||||
return {"success": True, "task_id": task_id, "task_name": task_data.get("name"),
|
||||
"project": task_data["project_id"][1] if task_data.get("project_id") else None,
|
||||
"current_assignees": assignees,
|
||||
"message": f"Task '{task_data.get('name')}' assigned to {len(user_ids)} user(s)."}
|
||||
|
||||
|
||||
def update_task_stage(task_id: int, stage_id: int, instance: str = "default") -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
try:
|
||||
rpc.write("project.task", [task_id], {"stage_id": stage_id})
|
||||
task = rpc.read("project.task", [task_id], ["name", "stage_id"])
|
||||
stage = rpc.read("project.task.type", [stage_id], ["name"])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
task_data = task[0] if task else {}
|
||||
stage_data = stage[0] if stage else {}
|
||||
_audit("update_task_stage", {"task_id": task_id, "stage_id": stage_id},
|
||||
f"Task moved to '{stage_data.get('name')}'", instance)
|
||||
return {"success": True, "task_id": task_id, "task_name": task_data.get("name"),
|
||||
"new_stage": stage_data.get("name"),
|
||||
"message": f"Task moved to stage '{stage_data.get('name')}'."}
|
||||
|
||||
|
||||
# ─── Customers / Sales ────────────────────────────────────────────────────────
|
||||
|
||||
def list_customers(
|
||||
name: Optional[str] = None, email: Optional[str] = None,
|
||||
is_company: Optional[bool] = None, limit: int = 50, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = [["customer_rank", ">", 0]]
|
||||
if name:
|
||||
domain.append(["name", "ilike", name])
|
||||
if email:
|
||||
domain.append(["email", "ilike", email])
|
||||
if is_company is not None:
|
||||
domain.append(["is_company", "=", is_company])
|
||||
try:
|
||||
partners = rpc.search_read(
|
||||
"res.partner", domain,
|
||||
["id", "name", "email", "phone", "is_company", "city", "country_id"],
|
||||
limit=limit, order="name asc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"customers": [
|
||||
{"id": p["id"], "name": p["name"], "email": p.get("email"), "phone": p.get("phone"),
|
||||
"type": "company" if p.get("is_company") else "individual",
|
||||
"city": p.get("city"),
|
||||
"country": p["country_id"][1] if p.get("country_id") else None}
|
||||
for p in partners
|
||||
],
|
||||
"total": len(partners),
|
||||
}
|
||||
|
||||
|
||||
def list_sales_orders(
|
||||
customer_id: Optional[int] = None, status: Optional[str] = None,
|
||||
limit: int = 50, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = []
|
||||
if customer_id:
|
||||
domain.append(["partner_id", "=", customer_id])
|
||||
if status:
|
||||
domain.append(["state", "=", status])
|
||||
try:
|
||||
orders = rpc.search_read(
|
||||
"sale.order", domain,
|
||||
["id", "name", "partner_id", "state", "date_order", "amount_total", "user_id"],
|
||||
limit=limit, order="date_order desc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
status_labels = {"draft": "Quotation", "sent": "Sent", "sale": "Confirmed",
|
||||
"done": "Locked", "cancel": "Cancelled"}
|
||||
return {
|
||||
"sales_orders": [
|
||||
{"id": o["id"], "reference": o["name"],
|
||||
"customer": o["partner_id"][1] if o.get("partner_id") else None,
|
||||
"status": status_labels.get(o.get("state", ""), o.get("state")),
|
||||
"state": o.get("state"), "order_date": o.get("date_order"),
|
||||
"total": o.get("amount_total"),
|
||||
"salesperson": o["user_id"][1] if o.get("user_id") else None}
|
||||
for o in orders
|
||||
],
|
||||
"total": len(orders),
|
||||
}
|
||||
|
||||
|
||||
def list_invoices(
|
||||
customer_id: Optional[int] = None, status: Optional[str] = None,
|
||||
invoice_type: str = "out_invoice", limit: int = 50, instance: str = "default",
|
||||
) -> dict:
|
||||
rpc = get_conn_manager().rpc(instance)
|
||||
domain: list = [["move_type", "=", invoice_type]]
|
||||
if customer_id:
|
||||
domain.append(["partner_id", "=", customer_id])
|
||||
if status:
|
||||
domain.append(["state", "=", status])
|
||||
try:
|
||||
invoices = rpc.search_read(
|
||||
"account.move", domain,
|
||||
["id", "name", "partner_id", "state", "invoice_date", "invoice_date_due",
|
||||
"amount_total", "amount_residual"],
|
||||
limit=limit, order="invoice_date desc",
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"invoices": [
|
||||
{"id": inv["id"], "reference": inv.get("name"),
|
||||
"customer": inv["partner_id"][1] if inv.get("partner_id") else None,
|
||||
"status": inv.get("state"), "invoice_date": inv.get("invoice_date"),
|
||||
"due_date": inv.get("invoice_date_due"), "total": inv.get("amount_total"),
|
||||
"amount_due": inv.get("amount_residual")}
|
||||
for inv in invoices
|
||||
],
|
||||
"total": len(invoices),
|
||||
}
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
123
tests/test_basic.py
Normal file
123
tests/test_basic.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Unit tests — no live Odoo or DB connection required."""
|
||||
from __future__ import annotations
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from mt_odoo_mcp.confirmation.manager import ConfirmationManager
|
||||
from mt_odoo_mcp.audit.logger import _sanitize
|
||||
|
||||
|
||||
# ─── Confirmation Manager ──────────────────────────────────────────────────────
|
||||
|
||||
def test_token_created():
|
||||
mgr = ConfirmationManager()
|
||||
pending = mgr.create_token("default", "project.task", 42, "Fix Bug")
|
||||
assert pending.token.startswith("del_")
|
||||
assert pending.model == "project.task"
|
||||
assert pending.record_id == 42
|
||||
|
||||
|
||||
def test_token_consumed():
|
||||
mgr = ConfirmationManager()
|
||||
pending = mgr.create_token("default", "res.partner", 99, "ACME Corp")
|
||||
ok, err = mgr.consume_token(pending.token, "res.partner", 99)
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_token_single_use():
|
||||
mgr = ConfirmationManager()
|
||||
pending = mgr.create_token("default", "sale.order", 7, "SO007")
|
||||
mgr.consume_token(pending.token, "sale.order", 7)
|
||||
ok, err = mgr.consume_token(pending.token, "sale.order", 7)
|
||||
assert ok is False
|
||||
assert "expired" in err.lower() or "invalid" in err.lower()
|
||||
|
||||
|
||||
def test_token_wrong_model():
|
||||
mgr = ConfirmationManager()
|
||||
pending = mgr.create_token("default", "project.task", 10, "Task A")
|
||||
ok, err = mgr.consume_token(pending.token, "sale.order", 10)
|
||||
assert ok is False
|
||||
assert "does not match" in err.lower()
|
||||
|
||||
|
||||
def test_token_wrong_record_id():
|
||||
mgr = ConfirmationManager()
|
||||
pending = mgr.create_token("default", "project.task", 10, "Task A")
|
||||
ok, _ = mgr.consume_token(pending.token, "project.task", 999)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_invalid_token():
|
||||
mgr = ConfirmationManager()
|
||||
ok, _ = mgr.consume_token("del_notarealtoken", "project.task", 1)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_list_pending():
|
||||
mgr = ConfirmationManager()
|
||||
mgr.create_token("default", "account.move", 55, "INV/2026/001")
|
||||
assert any(p["model"] == "account.move" for p in mgr.list_pending())
|
||||
|
||||
|
||||
def test_per_user_isolation():
|
||||
"""Two ConfirmationManager instances must not share tokens."""
|
||||
mgr_a = ConfirmationManager()
|
||||
mgr_b = ConfirmationManager()
|
||||
pending = mgr_a.create_token("default", "project.task", 1, "Task")
|
||||
ok, _ = mgr_b.consume_token(pending.token, "project.task", 1)
|
||||
assert ok is False # mgr_b has no knowledge of mgr_a's tokens
|
||||
|
||||
|
||||
# ─── Audit Logger Sanitization ─────────────────────────────────────────────────
|
||||
|
||||
def test_sanitize_password():
|
||||
assert _sanitize({"username": "admin", "password": "s3cret"})["password"] == "***"
|
||||
|
||||
|
||||
def test_sanitize_api_key():
|
||||
result = _sanitize({"api_key": "abc123", "model": "project.task"})
|
||||
assert result["api_key"] == "***"
|
||||
assert result["model"] == "project.task"
|
||||
|
||||
|
||||
def test_sanitize_nested():
|
||||
result = _sanitize({"instance": {"name": "prod", "api_key": "secret"}})
|
||||
assert result["instance"]["api_key"] == "***"
|
||||
assert result["instance"]["name"] == "prod"
|
||||
|
||||
|
||||
def test_sanitize_list_passthrough():
|
||||
result = _sanitize({"ids": [1, 2, 3], "token": "tok_abc"})
|
||||
assert result["ids"] == [1, 2, 3]
|
||||
assert result["token"] == "***"
|
||||
|
||||
|
||||
def test_sanitize_plain():
|
||||
data = {"model": "sale.order", "limit": 10}
|
||||
assert _sanitize(data) == data
|
||||
|
||||
|
||||
# ─── Auth Crypto ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_password_hash_verify():
|
||||
from mt_odoo_mcp.auth.crypto import hash_password, verify_password
|
||||
h = hash_password("mysecret123")
|
||||
assert verify_password("mysecret123", h)
|
||||
assert not verify_password("wrong", h)
|
||||
|
||||
|
||||
def test_api_key_format():
|
||||
from mt_odoo_mcp.auth.crypto import generate_api_key
|
||||
full, key_hash, prefix = generate_api_key()
|
||||
assert full.startswith("mtom_")
|
||||
assert len(full) > 20
|
||||
assert prefix == full[:12]
|
||||
|
||||
|
||||
def test_api_key_verify():
|
||||
from mt_odoo_mcp.auth.crypto import generate_api_key, verify_api_key
|
||||
full, key_hash, _ = generate_api_key()
|
||||
assert verify_api_key(full, key_hash)
|
||||
assert not verify_api_key("mtom_wrong", key_hash)
|
||||
Loading…
x
Reference in New Issue
Block a user