Saltar a contenido

Arquitectura

Para curiosos y contribuidores. La fuente autoritativa es docs/architecture.md del repositorio.

Novedades en v1.6.0

install_check.py nombra la copia de odoo-tools que responde (el bloque plugin de odoo_version) y corre sin conexión las revisiones de copias y de opciones (run_stdio.py --check-copies y --check-options). En Odoo 10 a 12, la capa B decide con los campos del modelo las claves de un objeto pasado por posición a un método de negocio.

Novedades en v1.5.1

diagnostics.py escribe el diagnóstico local de solo metadatos (server.jsonl), y run_stdio.py --diagnose imprime el reporte de soporte sin contactar a Odoo. La capa A, y en Odoo 10 a 12 también la capa B, leen como contexto un objeto pasado por posición. El servidor ya no lee ODOO_HTTP_BEARER.

Novedades en v1.5.0

La política de permisos decide cada llamada antes de que exista una sesión (capa A, policy/check.py), y una sesión vigilada revisa cada llamada a Odoo que hace una herramienta (capa B, policy/guard.py). audit.py escribe una línea por llamada. Se quitó el camino multi-tenant por cabeceras.

Claude (cliente MCP)
      │  stdio (JSON-RPC MCP)
      ▼
odoo_mcp.server  ──lista/despacha──▶  registry (ToolRegistry, Access por herramienta)
      │   policy/check.py: capa A,           │
      │   antes de cualquier sesión          ▼
      │                              tools/ (crud, meta, export, i18n,
      │                                      report, studio, online)
      │                                     │  resolve_model / resolve_fields
      │                                     ▼
      │                              compat/ (detect → deltas → resolve)
      │                                     │  ctx.session
      ▼                                     ▼
tenancy.ConnectionManager ──▶ policy/guard.GuardedSession (capa B: cada llamada a Odoo)
   (por tenant)                             │
                                            ▼
                              session.OdooSession ──▶ transport/
                              (auth, facts, cache)     fallback → json2 | jsonrpc | xmlrpc
                                                              │
                                                              ▼
                                                        Instancia Odoo
audit.py: una línea por llamada a herramienta (rechazos incluidos), archivos locales o stderr

Capas

  • transport/: Json2Transport (nuevo en v1.4.0: POST /json/2/<modelo>/<método> con una API key bearer; los argumentos posicionales se nombran con json2_signatures.py, o con /doc-bearer para otros métodos, y se corrigen una vez cuando la validación de Odoo rechaza los nombres de la sobrecarga de un modelo), JsonRpcTransport, XmlRpcTransport y FallbackTransport, que elige el orden según la serie sin acotar, el tipo de credencial y user:pass@ en la URL (auto: JSON-2 → JSON-RPC → XML-RPC en saas~18.4 / 19.0+ con API key, JSON-RPC → XML-RPC en los demás casos; solo JSON-2 desde saas~21.1). Detecta la versión sin iniciar sesión ni usar un endpoint obsoleto, fija el transporte que responde tras un fallo (y JSON-2 tras su primer inicio de sesión) y avisa una vez. Una escritura (cualquier llamada que no sea un método de lectura) pasa al siguiente transporte solo cuando es seguro que la petición no llegó a Odoo; tras agotarse el tiempo de espera, un HTTP 5xx o una respuesta ilegible se devuelve el error ("may or may not have been applied"). Las lecturas sí se repiten por el siguiente transporte. Un HTTP 429 es RateLimited: las lecturas esperan Retry-After en el mismo transporte y las escrituras se reportan. En Odoo 19+ con un transporte clásico expone transport_notice. Todos usan solo la biblioteca estándar (urllib y xmlrpc.client), comparten un contexto TLS que verifica certificados y respetan ODOO_TIMEOUT.
  • session.py: OdooSession guarda credenciales, autentica de forma perezosa (uid), cachea los facts de versión/edición/despliegue y envuelve las lecturas de esquema en la caché TTL SchemaCache. ConnectionManager (tenancy.py) mapea una clave de sesión (Credentials.session_key(): url|db|login, el tipo de credencial y un HMAC del secreto calculado por proceso, nunca el secreto mismo) a una sesión.
  • compat/: detect.probe() construye EnvFacts; deltas.py es el mapa declarativo de deltas; resolve.py convierte un modelo/campo/capacidad solicitado en lo que exista en el destino. Cada herramienta llama a resolve_* antes del ORM.
  • tools/: manejadores delgados registrados sobre el registry compartido. Las herramientas de escritura llevan read_only=False: ODOO_READONLY=1 las oculta y las rechaza en server.py antes de que exista una sesión. Cada herramienta declara además lo que toca (access=), para la política de permisos. El núcleo público tiene una política de permisos declarativa; los flujos de aprobación quedan fuera (pertenecen a una capa de gobernanza privada). tools/online.py contiene las herramientas de Odoo Online. Sus dos herramientas de importación limitan la llamada a JSON-2 y JSON-RPC (transports=("json2", "jsonrpc")), y el fallback nunca la envía ni la repite por XML-RPC, cuyo serializador no puede devolver los valores None que trae el resultado de una importación. session.api_doc(model) sirve /doc-bearer sin entregarle el secreto a una herramienta.
  • policy/: la política de permisos: los datos de niveles (tiers.py), las clases de métodos y las formas de argumentos (classify.py), los perfiles, las reglas y Policy.decide() (model.py), el cargador de las opciones y del archivo con sus códigos E y advertencias (loader.py), la capa A (check.py), la sesión vigilada de la capa B (guard.py), los textos que ve el agente (texts.py) y --check-policy (cli.py). Todo salvo el guard es puro: ninguna llamada a Odoo y ninguna E/S, salvo leer el archivo de política una vez al arrancar.
  • audit.py: una línea JSON encadenada por hash por llamada a herramienta, rechazos incluidos, solo con nombres y conteos, a archivos diarios solo para tu usuario (ODOO_AUDIT_DIR), a stderr, o a ninguna parte (off). --verify-audit ARCHIVO revisa la cadena de un archivo.
  • diagnostics.py (desde la v1.5.1): el diagnóstico local. Una línea JSON de solo metadatos por evento del servidor (start, connect, crash, stop) en server.jsonl, dentro de la carpeta ODOO_DIAG_DIR; cada clave pasa por una lista permitida, así que nunca guarda la URL, la base de datos, el login, una credencial, la ruta del archivo de política, datos de registros, argumentos ni textos de error. Las líneas miden menos de 2 KB, cada archivo rota a los 256 KiB y un fallo al escribir nunca llega a una llamada a herramienta. --diagnose lee esos archivos y la auditoría, y nunca contacta a Odoo.
  • install_check.py (desde la v1.6.0): qué copia de odoo-tools está instalada y si sus opciones están completas. plugin_identity lee la copia del nombre de la carpeta de datos que Claude Code da a cada copia (~/.claude/plugins/data/<nombre>-<marketplace>), y odoo_version la reporta en plugin. --check-copies lee en la entrada estándar la salida de claude plugin list --json, y --check-options la de claude plugin configure <id> --json: no corren ningún programa, no abren ningún archivo de Claude Code, nunca contactan a Odoo ni imprimen un valor sensible, y salen con 0 (nada que corregir), 1 (algo que corregir) o 2 (la entrada no es ese JSON).
  • observability.py: métricas Prometheus y trazas OTLP opcionales, ambas apagadas por defecto y no-ops si los extras no están instalados.
  • server.py / main.py / run_stdio.py: el protocolo MCP por stdio, implementado sobre la biblioteca estándar de Python (sin el SDK mcp ni otras dependencias): JSON-RPC 2.0 delimitado por saltos de línea con initialize (negocia la versión del protocolo: 2025-06-18, 2025-03-26 o 2024-11-05), ping, tools/list (con annotations.readOnlyHint), tools/call (los errores de herramienta vuelven como resultado con isError), las notificaciones initialized y cancelled, y lotes (batches). Las llamadas a herramientas corren en un solo hilo de trabajo, en orden de llegada: el lector sigue respondiendo ping y atendiendo cancelaciones mientras Odoo trabaja, y las sesiones/transportes nunca se usan en concurrencia. run_stdio.py es el punto de entrada que lanza el plugin (python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py); odoo-mcp / python -m odoo_mcp siguen disponibles en modo standalone, y también corren las revisiones sin conexión --check-policy ARCHIVO, --verify-audit ARCHIVO, --diagnose (el reporte de soporte), --check-copies y --check-options. Los logs van a stderr y cualquier print() suelto se redirige ahí; stdout es exclusivo del canal MCP.

Orden de las revisiones en una llamada a herramienta

  1. check_readonly (sin cambios): con ODOO_READONLY=1 una herramienta de escritura se rechaza, sean cuales sean sus argumentos.
  2. Policy.check_tool: se rechaza una política bloqueada, una herramienta sin mapeo, una herramienta desactivada o una que el perfil prohíbe en todos los modelos. Son las herramientas que oculta tools/list.
  3. Los argumentos se validan contra el esquema de entrada de la herramienta.
  4. Capa A (Policy.check): los toques declarados, las revisiones de nombres, NUNCA, los nombres de campos retenidos, el anclaje de ir.attachment, la regla de notas internas y las claves de contexto, todo a partir de los argumentos. Un rechazo aquí nunca inicia sesión y no se cuenta como llamada a herramienta.
  5. El manejador corre con GuardedSession (capa B): cada llamada a Odoo se revisa otra vez sobre el modelo y el método reales, con sus valores y rutas (comandos x2many, rutas de campos, adjuntos, identidad, enlaces de delegación y, en Odoo 10 a 12, las claves de un objeto pasado por posición, con los campos del modelo), contra los toques declarados de la herramienta o una de sus llamadas internas y sus predicados, antes de tocar el transporte. Los resultados de lectura pierden los campos retenidos (policy_withheld_fields).
  6. La auditoría escribe una línea por la llamada, sea cual sea el resultado.

Transportes y modos

  • stdio (por defecto): un solo tenant. Es el modo que usa la entrada mcpServers del plugin, que inyecta al proceso las credenciales desde las opciones del plugin; en modo standalone se leen de las variables ODOO_* del entorno.
  • HTTP (hoja de ruta, no construido): un modo remoto donde el servidor guarde la credencial de Odoo y resuelva una sesión a partir de una identidad autenticada. Necesita su propio diseño. El tenancy.from_headers basado en cabeceras (X-Odoo-Url/Db/Login elegidas por el cliente y un Bearer que se pasaba a Odoo, con respaldo al secreto propio del servidor) no tenía quien lo llamara y se quitó en la v1.5.0; ODOO_MULTITENANT se sigue leyendo, sin efecto. ODOO_HTTP_BEARER, que se leía pero nunca se usaba, se quitó en la v1.5.1: el servidor no lee ninguna credencial que .claude-plugin/plugin.json no le pase desde una opción sensitive.

Principios de diseño

  • Una sola superficie de herramientas entre versiones: quien llama usa nombres modernos.
  • Fallar con remediación: CompatError explica qué cambiar; los campos no disponibles degradan a advertencias, no a fallos duros.
  • Cero dependencias en tiempo de ejecución: solo la biblioteca estándar de Python 3.9+; los deps opcionales (cache/metrics/otel) nunca bloquean el arranque.
  • Sin secretos en logs ni en disco.