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 fromjson2_signatures.py, or from/doc-bearerfor other methods, and corrected once when Odoo's validation rejects the names of a model's override),JsonRpcTransport,XmlRpcTransportandFallbackTransport, which picks the order from the unclamped series, the credential kind anduser: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 isRateLimited: reads wait forRetry-Afteron the same transport and writes are reported. On Odoo 19+ with a legacy transport it exposestransport_notice. All of them use only the standard library (urllibandxmlrpc.client), share a certificate-verifying TLS context and honorODOO_TIMEOUT. - session.py:
OdooSessionstores credentials, authenticates lazily (uid), caches the version/edition/deployment facts and wraps the schema reads in theSchemaCacheTTL 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()buildsEnvFacts;deltas.pyis the declarative delta map;resolve.pyconverts a requested model/field/capability into whatever exists in the target. Each tool callsresolve_*before the ORM. - tools/: thin handlers registered on the shared
registry. The write tools carryread_only=False:ODOO_READONLY=1hides them and refuses them inserver.pybefore 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.pyholds 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 theNonevalues an import result carries.session.api_doc(model)serves/doc-bearerwithout handing the secret to a tool. - policy/: the permission policy: the tier data
(
tiers.py), method classes and argument shapes (classify.py), profiles, rules andPolicy.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 FILEchecks 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) inserver.jsonl, inside theODOO_DIAG_DIRfolder; 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.--diagnosereads 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_identityreads the copy from the name of the data folder that Claude Code gives each copy (~/.claude/plugins/data/<name>-<marketplace>), andodoo_versionreports it inplugin.--check-copiesreads on stdin the output ofclaude plugin list --json, and--check-optionsthat ofclaude 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
mcpSDK or other dependency): newline-delimited JSON-RPC 2.0 withinitialize(negotiates the protocol version:2025-06-18,2025-03-26or2024-11-05),ping,tools/list(withannotations.readOnlyHint),tools/call(tool errors come back as results withisError), theinitializedandcancellednotifications, and batches. Tool calls run on a single worker thread, in arrival order: the reader keeps answeringpingand honoring cancellations while Odoo works, and sessions/transports never see concurrent use.run_stdio.pyis the entry point the plugin launches (python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py);odoo-mcp/python -m odoo_mcpremain available in standalone mode, and also run the offline checks--check-policy FILE,--verify-audit FILE,--diagnose(the support report),--check-copiesand--check-options. The logs go to stderr and any strayprint()is redirected there; stdout is exclusive to the MCP channel.
Order of checks on a tool call¶
check_readonly(unchanged): withODOO_READONLY=1a write tool is refused, whatever its arguments.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 toolstools/listhides.- The arguments are validated against the tool's input schema.
- Layer A (
Policy.check): the declared touches, the name checks, NEVER, the withheld field names, their.attachmentpin, 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. - 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). - 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
mcpServersentry, which passes the credentials from the plugin options to the process; in standalone mode they are read from theODOO_*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-chosenX-Odoo-Url/Db/Loginand aBearerpassed through to Odoo, with a fallback to the server's own secret) had no caller and was removed in v1.5.0;ODOO_MULTITENANTis 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.jsondoes not pass from asensitiveoption.
Design principles¶
- A single tool surface across versions: the caller uses modern names.
- Fail with remediation:
CompatErrorexplains 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.