Skip to content

Architecture

For the curious and for contributors. The authoritative source is the repository's docs/architecture.md.

New in v1.6.0

install_check.py names the odoo-tools copy that answers (the plugin block of odoo_version) and runs the copies check and the options check offline (run_stdio.py --check-copies and --check-options). On Odoo 10 to 12, Layer B decides with the model's fields the keys of an object passed positionally to a business method.

New in v1.5.1

diagnostics.py writes the metadata-only local diagnostics (server.jsonl), and run_stdio.py --diagnose prints the support report without contacting Odoo. Layer A, and on Odoo 10 to 12 Layer B too, read an object passed positionally as a context. The server no longer reads ODOO_HTTP_BEARER.

New in v1.5.0

The permission policy decides each call before any session exists (Layer A, policy/check.py), and a guarded session checks every Odoo call a tool makes (Layer B, policy/guard.py). audit.py writes one line per call. The header-based multi-tenant path was removed.

Claude (MCP client)
      │  stdio (JSON-RPC MCP)
      ▼
odoo_mcp.server  ──lists/dispatches──▶  registry (ToolRegistry, Access per tool)
      │   policy/check.py: Layer A,          │
      │   before any session                 ▼
      │                              tools/ (crud, meta, export, i18n,
      │                                      report, studio, online)
      │                                     │  resolve_model / resolve_fields
      │                                     ▼
      │                              compat/ (detect → deltas → resolve)
      │                                     │  ctx.session
      ▼                                     ▼
tenancy.ConnectionManager ──▶ policy/guard.GuardedSession (Layer B: every Odoo call)
   (per-tenant)                             │
                                            ▼
                              session.OdooSession ──▶ transport/
                              (auth, facts, cache)     fallback → json2 | jsonrpc | xmlrpc
                                                              │
                                                              ▼
                                                        Odoo instance
audit.py: one line per tool call (refusals included), local files or stderr

Layers

  • transport/: Json2Transport (new in v1.4.0: POST /json/2/<model>/<method> with a bearer API key; positional arguments are named from json2_signatures.py, or from /doc-bearer for other methods, and corrected once when Odoo's validation rejects the names of a model's override), JsonRpcTransport, XmlRpcTransport and FallbackTransport, which picks the order from the unclamped series, the credential kind and user:pass@ in the URL (auto: JSON-2 → JSON-RPC → XML-RPC on saas~18.4 / 19.0+ with an API key, JSON-RPC → XML-RPC otherwise; JSON-2 only from saas~21.1). It detects the version without signing in or using a deprecated endpoint, pins the transport that answers after a failure (and JSON-2 after its first sign-in) and warns once. A write (any call that is not a read method) moves to the next transport only when the request certainly never reached Odoo; after a timeout, an HTTP 5xx or a garbled reply the error is raised instead ("may or may not have been applied"). Reads are still retried over the next transport. An HTTP 429 is RateLimited: reads wait for Retry-After on the same transport and writes are reported. On Odoo 19+ with a legacy transport it exposes transport_notice. All of them use only the standard library (urllib and xmlrpc.client), share a certificate-verifying TLS context and honor ODOO_TIMEOUT.
  • session.py: OdooSession stores credentials, authenticates lazily (uid), caches the version/edition/deployment facts and wraps the schema reads in the SchemaCache TTL cache. ConnectionManager (tenancy.py) maps a session key (Credentials.session_key(): url|db|login, the credential kind and a per-process HMAC of the secret, never the secret itself) to a session.
  • compat/: detect.probe() builds EnvFacts; deltas.py is the declarative delta map; resolve.py converts a requested model/field/capability into whatever exists in the target. Each tool calls resolve_* before the ORM.
  • tools/: thin handlers registered on the shared registry. The write tools carry read_only=False: ODOO_READONLY=1 hides them and refuses them in server.py before any session exists. Each tool also declares what it touches (access=), for the permission policy. The public core has a declarative permission policy; approval workflows stay out (they belong to a private governance layer). tools/online.py holds the Odoo Online tools. Its two import tools limit the call to JSON-2 and JSON-RPC (transports=("json2", "jsonrpc")), and the fallback never sends or replays it over XML-RPC, whose marshaller cannot return the None values an import result carries. session.api_doc(model) serves /doc-bearer without handing the secret to a tool.
  • policy/: the permission policy: the tier data (tiers.py), method classes and argument shapes (classify.py), profiles, rules and Policy.decide() (model.py), the loader of the options and the file with its E-codes and warnings (loader.py), Layer A (check.py), the guarded session of Layer B (guard.py), the texts the agent sees (texts.py) and --check-policy (cli.py). Everything except the guard is pure: no Odoo call and no I/O except reading the policy file once at start.
  • audit.py: one hash-chained JSON line per tool call, refusals included, with names and counts only, to daily files readable by your user alone (ODOO_AUDIT_DIR), to stderr, or nowhere (off). --verify-audit FILE checks a file's chain.
  • diagnostics.py (since v1.5.1): the local diagnostics. One metadata-only JSON line per server event (start, connect, crash, stop) in server.jsonl, inside the ODOO_DIAG_DIR folder; every key goes through an allowlist, so it never holds the URL, the database, the login, a credential, the policy file path, record data, arguments or error texts. Lines stay under 2 KB, each file rotates at 256 KiB, and a failure to write never reaches a tool call. --diagnose reads those files and the audit, and never contacts Odoo.
  • install_check.py (since v1.6.0): which odoo-tools copy is installed, and whether its options are complete. plugin_identity reads the copy from the name of the data folder that Claude Code gives each copy (~/.claude/plugins/data/<name>-<marketplace>), and odoo_version reports it in plugin. --check-copies reads on stdin the output of claude plugin list --json, and --check-options that of claude plugin configure <id> --json: they run no program, open no file of Claude Code's, never contact Odoo or print a sensitive value, and exit 0 (nothing to fix), 1 (something to fix) or 2 (the input is not that JSON).
  • observability.py: optional Prometheus metrics and OTLP traces, both off by default and no-ops if the extras are not installed.
  • server.py / main.py / run_stdio.py: the MCP stdio protocol, implemented on the Python standard library (no mcp SDK or other dependency): newline-delimited JSON-RPC 2.0 with initialize (negotiates the protocol version: 2025-06-18, 2025-03-26 or 2024-11-05), ping, tools/list (with annotations.readOnlyHint), tools/call (tool errors come back as results with isError), the initialized and cancelled notifications, and batches. Tool calls run on a single worker thread, in arrival order: the reader keeps answering ping and honoring cancellations while Odoo works, and sessions/transports never see concurrent use. run_stdio.py is the entry point the plugin launches (python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py); odoo-mcp / python -m odoo_mcp remain available in standalone mode, and also run the offline checks --check-policy FILE, --verify-audit FILE, --diagnose (the support report), --check-copies and --check-options. The logs go to stderr and any stray print() is redirected there; stdout is exclusive to the MCP channel.

Order of checks on a tool call

  1. check_readonly (unchanged): with ODOO_READONLY=1 a write tool is refused, whatever its arguments.
  2. Policy.check_tool: a locked policy, a tool without a mapping, a disabled tool, or one the profile forbids on every model is refused. These are the tools tools/list hides.
  3. The arguments are validated against the tool's input schema.
  4. Layer A (Policy.check): the declared touches, the name checks, NEVER, the withheld field names, the ir.attachment pin, the internal-note rule and the context keys, all from the arguments. A refusal here never signs in and is not counted as a tool call.
  5. The handler runs with GuardedSession (Layer B): each Odoo call is checked again on the actual model and method, with its values and paths (x2many commands, field paths, attachments, identity, delegation links and, on Odoo 10 to 12, the keys of an object passed positionally, with the model's fields), against the tool's declared touches or one of its internal calls and their predicates, before the transport is touched. Read results lose withheld fields (policy_withheld_fields).
  6. The audit writes one line for the call, whatever the outcome.

Transports and modes

  • stdio (default): a single tenant. It is the mode used by the plugin's mcpServers entry, which passes the credentials from the plugin options to the process; in standalone mode they are read from the ODOO_* environment variables.
  • HTTP (roadmap, not built): a remote mode where the server keeps the Odoo credential and resolves a session from an authenticated identity. It needs its own design. The header-based tenancy.from_headers (client-chosen X-Odoo-Url/Db/Login and a Bearer passed through to Odoo, with a fallback to the server's own secret) had no caller and was removed in v1.5.0; ODOO_MULTITENANT is still parsed, with no effect. ODOO_HTTP_BEARER, parsed but never used, was removed in v1.5.1: the server reads no credential that .claude-plugin/plugin.json does not pass from a sensitive option.

Design principles

  • A single tool surface across versions: the caller uses modern names.
  • Fail with remediation: CompatError explains what to change; the unavailable fields degrade to warnings, not to hard failures.
  • Zero runtime dependencies: Python 3.9+ standard library only; the optional deps (cache/metrics/otel) never block startup.
  • No secrets in logs or on disk.