mcp-server: architecture of the Odoo module mcp_server
======================================================

Package odoo-addon-mcp_server, Odoo module mcp_server 19.0.2.0.0, licence OPL-1 (LICENSE in this
directory). Every statement below was read in the module source shipped with this package.

Role
----
mcp_server turns an Odoo instance into a Model Context Protocol (MCP) server. An AI assistant or
any MCP client connects to the HTTP endpoint of Odoo itself: there is no separate process to run,
no daemon, no rc.d script and no file under /etc. Everything the module does happens inside the
Odoo workers that serve www/odoo. The module depends on base, base_setup, mail, rpc and web
(__manifest__.py), and on three Python libraries: authlib (>=1.6.12,<1.7.0), defusedxml and
packaging, which this package requires.

Request flow
------------
    MCP client --TLS--> reverse proxy :443 --HTTP--> Odoo workers --> PostgreSQL

In this fleet the proxy is relayd(8) and Odoo listens on 127.0.0.1:8069 with proxy_mode on
(infra.git roles/app_odoo, odoo.conf.j2 and app_odoo__web_relayd). The module itself is proxy
agnostic: the OAuth issuer and the protected resource are derived from the host of each request
(controllers/oauth_server.py, _base_url), so the public URL is whatever the proxy presents.

Routes served by Odoo:

  POST /mcp, POST /mcp/rpc    MCP JSON-RPC 2.0 endpoint; /mcp/rpc is an alias of /mcp.
                              Methods: initialize, ping, tools/list, tools/call,
                              resources/templates/list, resources/list, resources/read, and
                              any notifications/*. Protocol versions 2025-11-25 and 2025-06-18.
                              Body limited to 10 MiB. Bearer authentication, see below.
  GET  /mcp/health            liveness and module version, no authentication.
  GET  /mcp/system/info, /mcp/auth/validate, /mcp/models, /mcp/models/<model>/access
                              legacy REST routes, X-API-Key header.
  POST /mcp/xmlrpc/common, /mcp/xmlrpc/db, /mcp/xmlrpc/object
                              legacy XML-RPC proxies.
  GET  /.well-known/oauth-protected-resource[/mcp]    RFC 9728 metadata.
  GET  /.well-known/oauth-authorization-server        RFC 8414 metadata.
  GET/POST /mcp/oauth/authorize    consent screen; the user logs into Odoo (session, CSRF on).
  POST /mcp/oauth/token            code and refresh-token exchange.
  POST /mcp/oauth/register         RFC 7591 dynamic client registration, rate limited per IP.

All /mcp routes are inert until the master switch is turned on: ir.config_parameter
mcp_server.enabled defaults to False. The OAuth routes also need mcp_server.enable_oauth, which
defaults to True.

Tools
-----
The builtin tools are: list_models, get_record, get_fields, search_records, aggregate_records,
list_resource_templates, get_current_context (read), create_record, update_record, delete_record,
call_model_method and post_message (write). Administrators can add curated tools that wrap an
ir.actions.server (model mcp.custom.tool). A session granted read access only does not see the
write tools.

Models and groups
-----------------
  mcp.enabled.model              per-model opt-in: allow_read (default on), allow_create,
                                 allow_write, allow_unlink, allow_method_calls (default off).
  mcp.custom.tool                custom tools.
  mcp.log                        audit log.
  mcp.oauth.client               OAuth clients, registered by hand or dynamically.
  mcp.oauth.token                access and refresh tokens, only their SHA-256 hashes stored.
  mcp.oauth.authorization.code   short-lived authorization codes.
  wizards                        mcp.model.selection.wizard, mcp.oauth.bulk.confirm.wizard.
Inherited: res.config.settings, res.users.apikeys, ir.actions.server, ir.http.

Groups: MCP User (group_mcp_user, implies Internal User) and MCP Administrator (group_mcp_admin,
implies MCP User, granted to the admin user and implied by Settings administrators). Every tool
runs under the Odoo access rights of the authenticated user, record rules included.

Authentication
--------------
/mcp accepts Authorization: Bearer <token>, tried in this order (models/ir_http.py):
  1. an Odoo API key of scope mcp or rpc, or an unscoped one. The profile key dialog offers
     "MCP only", which stores scope mcp: such a key works on /mcp only.
  2. an OAuth 2.1 access token from the module's own authorization server (authlib), audience
     bound to <public URL>/mcp (RFC 8707).
The authorization server supports response type code, grants authorization_code and
refresh_token, PKCE S256 only, public clients (token_endpoint_auth_method none), scopes mcp,
mcp:read and mcp:write. Access tokens live 3600 s, refresh tokens 30 days, authorization codes
60 s. At the consent screen the user may withhold write access, which yields a read-only session.
Without credentials /mcp answers 401 with the RFC 9728 pointer.

Configuration
-------------
Everything is in ir.config_parameter, edited in Odoo under Settings, block "MCP Server"; nothing
is read from /etc, and the package installs no configuration file. Keys, with defaults:
  mcp_server.enabled False, mcp_server.enable_oauth True, mcp_server.enable_rate_limiting False,
  mcp_server.request_limit 300 (per user per minute; counted per worker process, so the effective
  ceiling is workers x limit), mcp_server.enable_logging True, mcp_server.log_retention_days 30
  (0 keeps everything), mcp_server.default_limit 10, mcp_server.max_limit,
  mcp_server.max_smart_fields, mcp_server.max_related_items, mcp_server.allowed_origins
  (Origin allowlist for browsers; empty accepts any Origin, requests without Origin always pass).

Logging
-------
Two channels. Audit rows go to the database table behind mcp.log (menu MCP, "MCP Logs"): event
types auth_success, auth_failure, model_access, resource_retrieval, write_operation, error,
rate_limit and permission_denied. Python diagnostics go through the logging module to the Odoo
log file, /var/log/odoo.log in this fleet (logfile of odoo.conf). That file is rotated by the odoo
deployment (infra.git roles/app_odoo, app_odoo__sys_newsyslog), so this package ships no
newsyslog entry.

Scheduled work
--------------
No system cron. Two Odoo scheduled actions (ir.cron), both daily:
  "MCP: OAuth Credential Garbage Collection"   data/oauth_cron.xml, noupdate; removes expired
                                               codes and tokens and abandoned dynamic clients.
  "MCP Log Cleanup"                            created on registry load if absent
                                               (mcp.log _register_hook), deletes mcp.log rows older
                                               than mcp_server.log_retention_days.

Upgrade and rollback
--------------------
The module code is the package. pkg_add -u replaces the files under
/usr/local/lib/python3.13/site-packages/odoo/addons/mcp_server (python version of www/odoo); it
does not touch the database. The new code is applied by updating the module, odoo -u mcp_server,
which in this fleet the play does: a changed odoo-addon-* package name makes the app_odoo role
replay the features, hn-odoo-setup.sh apply running -u on the modules of each (infra.git
roles/app_odoo). Odoo workers must be restarted to load the new code.
Rollback: stop Odoo, install the previous package, update the module again. A schema change made
by a newer version is not undone by the package; restore the database for that.

Security notes
--------------
- Writes, deletes and method calls are off for every model until an administrator opts in.
- call_model_method never reaches private methods, web_* methods or the generic ORM methods.
- /mcp is csrf-exempt and CORS is open ("*"): the defence is the bearer token, plus the optional
  Origin allowlist.
- Authentication failures are audited, and the audit write is throttled per IP.
- OAuth bodies are limited to 256 KiB; dynamic registration is limited to 20 per hour and per IP,
  counted in memory by each worker process (_DCR_MAX_REGISTRATIONS, _DCR_WINDOW_SECONDS).
- Tool errors are sanitized before they reach the client (controllers/error_sanitizer.py).

Licence
-------
Odoo Proprietary License v1.0. "It is forbidden to publish, distribute, sublicense, or sell
copies of the Software" (LICENSE): this package is built for the private package repository of
this fleet and is not part of the OpenBSD ftp tree. The licence text is installed as
/usr/local/share/doc/mcp-server/LICENSE and kept in the module directory.
