diff --git a/README.md b/README.md index 6f09134e..34028262 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Available addons ---------------- addon | version | maintainers | summary --- | --- | --- | --- +[ai_oca_mcp](ai_oca_mcp/) | 19.0.1.0.0 | | MCP Interface for Odoo [ai_tool](ai_tool/) | 19.0.1.0.0 | | We want to generate some specific AI Tools that might be used in other places, like MCP or native. [//]: # (end addons) diff --git a/ai_oca_mcp/README.rst b/ai_oca_mcp/README.rst new file mode 100644 index 00000000..0045b2e6 --- /dev/null +++ b/ai_oca_mcp/README.rst @@ -0,0 +1,194 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========== +Ai Oca Mcp +========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3d96a5eaf8e647e8ed470a84e39780908086610d1c72402ea1a85830b2d0e709 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/19.0/ai_oca_mcp + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_oca_mcp + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Expose Odoo as an MCP (Model Context Protocol) server, allowing external +AI clients — such as n8n, custom agents, or any MCP-compatible tool — to +invoke Odoo functions through the standardized MCP protocol. + +Each MCP server publishes a set of **AI tools** (defined by the +``ai_tool`` module). Clients exchange JSON-RPC 2.0 messages with Odoo +over HTTP POST: ``tools/list`` returns the tool catalogue and +``tools/call`` executes a tool. + +Key characteristics: + +- **Authentication**: per-client API keys sent as Bearer tokens. Keys + are stored hashed and are shown only once, at creation. +- **Per-user permissions**: every key is bound to an Odoo user and calls + execute with that user's permissions — a client can only do what its + key's user can do. +- **Key lifecycle**: keys can carry an expiration date or be expired + manually at any time. +- **Audit log**: every tool invocation is recorded with its parameters + and result or error. + +Notes: + +- Transport is plain JSON-RPC over HTTP POST. SSE/streaming transports + and notifications are not supported. +- Claude Desktop requires OAuth 2.0 authorization. Odoo does not act as + an OAuth authorization server, so clients that mandate OAuth need an + extension (for example, validating externally-issued tokens via + ``auth_jwt`` from OCA/server-auth). + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Configure an MCP server +----------------------- + +1. Activate developer mode and go to + ``Settings > Technical > AI > MCP Server``. +2. Create a server, give it a name, and select the ``generic`` tools you + want to expose. +3. The ``URL`` field shows the client endpoint — it already includes the + server's unique path key. +4. Optionally fill ``Instructions``: the ``initialize`` handshake always + returns an auto-generated capability summary (the server's purpose + and its tool list); this field appends extra guidance to it — domain + context, conventions, which tools to prefer. Per-tool ``description`` + and input schemas remain the primary channel agents rely on, so write + them as if they were documentation for the model. + +Create a client key +------------------- + +1. Click **Add Key** on the server form. +2. Choose the user the client will act as and, optionally, an expiration + date. +3. Copy the generated key — it is stored hashed and **shown only once**. + +Configure your client with: + +- **URL**: the server's ``URL`` field +- **Authentication**: Bearer token +- **Token**: the generated API key + +Connecting from n8n +~~~~~~~~~~~~~~~~~~~ + +Use the *MCP Client* node with: + +- **Endpoint**: the server's ``URL`` field +- **Authentication**: Bearer Token +- **Token**: the generated API key +- **Transport**: HTTP — the endpoint expects POST requests; SSE is not + supported + +Tool limitations +---------------- + +Only tools of kind ``generic`` are exposed. Tools requiring a record +context (``generic_model``, ``record``) cannot be called via MCP. + +``ai_tool`` itself ships only demo tools (``get_date``, +``post_message``), of which just ``get_date`` is callable via MCP. +Generic read access to Odoo data — listing models, describing fields and +``search_read`` — is provided by the ``ai_tool_read`` addon, which also +adds a per-field AI opt-out for sensitive data. Other capabilities — +creating documents, triggering actions — come from extension modules +that define their own ``@aitool``-decorated methods and ``ai.tool`` +records (see the ``ai_tool`` documentation). Whatever a tool can do is +limited by the permissions of the user bound to the API key. + +Tools are not limited to reading data: a tool method runs real ORM code +and can also create or update records and call actions, always within +the key owner's permissions. Results must be JSON-serializable — return +plain dicts and convert dates, recordsets and binary values yourself — +otherwise the call is logged and returns a JSON-RPC error. + +Security and auditing +--------------------- + +- Create one key per client, so a compromised client can be revoked + without affecting the others. +- Calls execute with the key owner's permissions: prefer a dedicated + user with minimal access rights to limit what clients can reach. +- Internal users can view their own keys and the related servers, but + only administrators can create or modify servers and keys. +- Keys can be expired manually from the server form, or automatically + once they reach their expiration date. +- ``AI > MCP Server Log`` records every tool invocation with its + parameters and result or error — use it to audit client activity. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +- Pierre Verkest pierre@verkest.fr +- Angel Moya amoyapardo@gmail.com +- Daniel Reis dreis.pt@gmail.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_oca_mcp/__init__.py b/ai_oca_mcp/__init__.py new file mode 100644 index 00000000..ada0b87b --- /dev/null +++ b/ai_oca_mcp/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizards diff --git a/ai_oca_mcp/__manifest__.py b/ai_oca_mcp/__manifest__.py new file mode 100644 index 00000000..1a37524b --- /dev/null +++ b/ai_oca_mcp/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Ai Oca Mcp", + "summary": """MCP Interface for Odoo""", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "depends": [ + "ai_tool", + ], + "data": [ + "views/mcp_server_log.xml", + "security/ir.model.access.csv", + "security/security.xml", + "views/mcp_server_key.xml", + "wizards/mcp_server_key_add.xml", + "views/mcp_server.xml", + ], + "demo": [], +} diff --git a/ai_oca_mcp/controllers/__init__.py b/ai_oca_mcp/controllers/__init__.py new file mode 100644 index 00000000..350e2127 --- /dev/null +++ b/ai_oca_mcp/controllers/__init__.py @@ -0,0 +1 @@ +from . import mcp_controller diff --git a/ai_oca_mcp/controllers/mcp_controller.py b/ai_oca_mcp/controllers/mcp_controller.py new file mode 100644 index 00000000..4fa3ad5b --- /dev/null +++ b/ai_oca_mcp/controllers/mcp_controller.py @@ -0,0 +1,78 @@ +import json +import re + +from odoo import fields, http +from odoo.http import request + + +class McpController(http.Controller): + @http.route( + "/mcp/", + type="http", + auth="none", + methods=["POST"], + csrf=False, + readonly=False, + ) + def mcp_endpoint(self, key, **kwargs): + match = re.match( + r"Bearer (.+)", request.httprequest.headers.get("Authorization", "") + ) + payload = json.loads(request.httprequest.data.decode("utf-8")) + if not match: + return request.make_json_response( + { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32000, "message": "Connection failed"}, + }, + status=401, + ) + security_key = match.group(1).strip() + server_id, expiration_date = ( + request.env["mcp.server.key"] + .sudo() + ._get_mcp_server_by_key(key, security_key) + ) + if expiration_date and expiration_date < fields.Datetime.now(): + request.env["mcp.server.key"].sudo().browse(server_id).expire_key() + server_id = False + if not server_id: + return request.make_json_response( + { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32000, "message": "Connection failed"}, + } + ) + mcp_server_key = request.env["mcp.server.key"].sudo().browse(server_id) + mcp_server_key = mcp_server_key.with_user(mcp_server_key.user_id.id) + method = payload.get("method") + if method == "initialize": + result = { + "protocolVersion": "2025-03-26", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "odoo-mcp", "version": "0.1.0"}, + "instructions": mcp_server_key.server_id._get_instructions(), + } + return request.make_json_response( + { + "jsonrpc": "2.0", + "id": payload.get("id"), + "result": result, + } + ) + + if method == "tools/list": + return request.make_json_response(mcp_server_key._tools_list(payload)) + + if method == "tools/call": + return request.make_json_response(mcp_server_key._tools_call(payload)) + + return request.make_json_response( + { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32601, "message": "Method not found"}, + } + ) diff --git a/ai_oca_mcp/i18n/ai_oca_mcp.pot b/ai_oca_mcp/i18n/ai_oca_mcp.pot new file mode 100644 index 00000000..2cc42540 --- /dev/null +++ b/ai_oca_mcp/i18n/ai_oca_mcp.pot @@ -0,0 +1,269 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_oca_mcp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key_ids +msgid "Access Keys" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__active +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__active +msgid "Active" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Add Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_key_add_act_window +msgid "Add Security Key" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Archived" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Cancel" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_date +msgid "Created on" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__date +msgid "Date" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__description +msgid "Description" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__error +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Error" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expiration_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_form_view +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_tree_view +msgid "Expire Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__expired +msgid "Expired" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expired_on +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expired_on +msgid "Expired On" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Generate Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__hashed_key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__hashed_key +msgid "Hashed Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__id +msgid "ID" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key_id +msgid "Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key +msgid "Key to use" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Keys" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log____last_update +msgid "Last Modified on" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_act_window +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_menu +msgid "MCP Server" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_log_menu +msgid "MCP Server Log" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server +msgid "Mcp Server" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key +msgid "Mcp Server Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_act_window +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_server_filter_act_window +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_log +msgid "Mcp Server Log" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__name +msgid "Name" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Name..." +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__request +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Request" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__response +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Response" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_id +msgid "Server" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_key_id +msgid "Server Key" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Show logs" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__state +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__state +msgid "State" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_key_uniq +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_uniq +msgid "The key must be unique" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__tool_ids +msgid "Tools" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__url +msgid "Url" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__user_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__user_id +msgid "User" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key_add +msgid "Wizard used to add a new key to an MCP Server" +msgstr "" diff --git a/ai_oca_mcp/i18n/gl.po b/ai_oca_mcp/i18n/gl.po new file mode 100644 index 00000000..597ef17e --- /dev/null +++ b/ai_oca_mcp/i18n/gl.po @@ -0,0 +1,272 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_oca_mcp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-07-06 23:46+0000\n" +"Last-Translator: Cándido Martínez \n" +"Language-Team: none\n" +"Language: gl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.15.2\n" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key_ids +msgid "Access Keys" +msgstr "Chaves de acceso" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__active +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__active +msgid "Active" +msgstr "Activo" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Add Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_key_add_act_window +msgid "Add Security Key" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Archived" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Cancel" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_date +msgid "Created on" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__date +msgid "Date" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__description +msgid "Description" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__error +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Error" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expiration_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_form_view +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_tree_view +msgid "Expire Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__expired +msgid "Expired" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expired_on +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expired_on +msgid "Expired On" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Generate Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__hashed_key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__hashed_key +msgid "Hashed Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__id +msgid "ID" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key_id +msgid "Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key +msgid "Key to use" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Keys" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log____last_update +msgid "Last Modified on" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_act_window +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_menu +msgid "MCP Server" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_log_menu +msgid "MCP Server Log" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server +msgid "Mcp Server" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key +msgid "Mcp Server Key" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_act_window +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_server_filter_act_window +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_log +msgid "Mcp Server Log" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__name +msgid "Name" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Name..." +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__request +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Request" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__response +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Response" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_id +msgid "Server" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_key_id +msgid "Server Key" +msgstr "" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Show logs" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__state +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__state +msgid "State" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_key_uniq +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_uniq +msgid "The key must be unique" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__tool_ids +msgid "Tools" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__url +msgid "Url" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__user_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__user_id +msgid "User" +msgstr "" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key_add +msgid "Wizard used to add a new key to an MCP Server" +msgstr "" diff --git a/ai_oca_mcp/i18n/it.po b/ai_oca_mcp/i18n/it.po new file mode 100644 index 00000000..f4954195 --- /dev/null +++ b/ai_oca_mcp/i18n/it.po @@ -0,0 +1,273 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_oca_mcp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-05-29 13:46+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.15.2\n" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key_ids +msgid "Access Keys" +msgstr "Chiavi accesso" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__active +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__active +msgid "Active" +msgstr "Attivo" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Add Key" +msgstr "Aggiungi chiave" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_key_add_act_window +msgid "Add Security Key" +msgstr "Aggiungi chiave di sicurezza" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Archived" +msgstr "In archivio" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Cancel" +msgstr "Annulla" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__date +msgid "Date" +msgstr "Data" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__description +msgid "Description" +msgstr "Descrizione" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__error +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Error" +msgstr "Errore" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expiration_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expiration_date +msgid "Expiration Date" +msgstr "Data di scadenza" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_form_view +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_tree_view +msgid "Expire Key" +msgstr "Chiave scadenza" + +#. module: ai_oca_mcp +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__expired +msgid "Expired" +msgstr "Scaduta" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expired_on +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expired_on +msgid "Expired On" +msgstr "Scaduta il" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Generate Key" +msgstr "Genera chiave" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__hashed_key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__hashed_key +msgid "Hashed Key" +msgstr "Chiave codificata" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__id +msgid "ID" +msgstr "ID" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key_id +msgid "Key" +msgstr "Chiave" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key +msgid "Key to use" +msgstr "Chiave da usare" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Keys" +msgstr "Chiavi" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_act_window +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_menu +msgid "MCP Server" +msgstr "Server MCP" + +#. module: ai_oca_mcp +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_log_menu +msgid "MCP Server Log" +msgstr "Registro server MCP" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server +msgid "Mcp Server" +msgstr "Server MCP" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key +msgid "Mcp Server Key" +msgstr "Chiave server MCP" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_act_window +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_server_filter_act_window +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_log +msgid "Mcp Server Log" +msgstr "Registro server MCP" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__name +msgid "Name" +msgstr "Nome" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Name..." +msgstr "Nome..." + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__request +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Request" +msgstr "Richiesta" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__response +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Response" +msgstr "Risposta" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_id +msgid "Server" +msgstr "Server" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_key_id +msgid "Server Key" +msgstr "Chiave server" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Show logs" +msgstr "Visualizza registri" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__state +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__state +msgid "State" +msgstr "Stato" + +#. module: ai_oca_mcp +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_key_uniq +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_uniq +msgid "The key must be unique" +msgstr "La chiave deve essere univoca" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__tool_ids +msgid "Tools" +msgstr "Strumenti" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__url +msgid "Url" +msgstr "URL" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__user_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__user_id +msgid "User" +msgstr "Utente" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key_add +msgid "Wizard used to add a new key to an MCP Server" +msgstr "" +"Procedura guidata utilizzata per aggiungere una nuova chiave ad un server MCP" diff --git a/ai_oca_mcp/i18n/pt_BR.po b/ai_oca_mcp/i18n/pt_BR.po new file mode 100644 index 00000000..f19e4f1e --- /dev/null +++ b/ai_oca_mcp/i18n/pt_BR.po @@ -0,0 +1,272 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_oca_mcp +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-06-15 11:07+0000\n" +"Last-Translator: felipemotter \n" +"Language-Team: none\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 5.15.2\n" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key_ids +msgid "Access Keys" +msgstr "Chaves de acesso" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__active +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__active +msgid "Active" +msgstr "Ativo" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Add Key" +msgstr "Adicionar chave" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_key_add_act_window +msgid "Add Security Key" +msgstr "Adicionar chave de segurança" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Archived" +msgstr "Arquivado" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Cancel" +msgstr "Cancelar" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_uid +msgid "Created by" +msgstr "Criado por" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__create_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__create_date +msgid "Created on" +msgstr "Criado em" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__date +msgid "Date" +msgstr "Data" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__description +msgid "Description" +msgstr "Descrição" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__display_name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__display_name +msgid "Display Name" +msgstr "Nome exibido" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__error +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Error" +msgstr "Erro" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expiration_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expiration_date +msgid "Expiration Date" +msgstr "Data de expiração" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_form_view +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_tree_view +msgid "Expire Key" +msgstr "Expirar chave" + +#. module: ai_oca_mcp +#: model:ir.model.fields.selection,name:ai_oca_mcp.selection__mcp_server_key__state__expired +msgid "Expired" +msgstr "Expirada" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__expired_on +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__expired_on +msgid "Expired On" +msgstr "Expirada em" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_key_add_form_view +msgid "Generate Key" +msgstr "Gerar chave" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__hashed_key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__hashed_key +msgid "Hashed Key" +msgstr "Chave com hash" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__id +msgid "ID" +msgstr "ID" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__key +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key_id +msgid "Key" +msgstr "Chave" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__key +msgid "Key to use" +msgstr "Chave a usar" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Keys" +msgstr "Chaves" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add____last_update +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log____last_update +msgid "Last Modified on" +msgstr "Última modificação em" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_uid +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_uid +msgid "Last Updated by" +msgstr "Última atualização por" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__write_date +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__write_date +msgid "Last Updated on" +msgstr "Última atualização em" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_act_window +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_menu +msgid "MCP Server" +msgstr "Servidor MCP" + +#. module: ai_oca_mcp +#: model:ir.ui.menu,name:ai_oca_mcp.mcp_server_log_menu +msgid "MCP Server Log" +msgstr "Log do servidor MCP" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server +msgid "Mcp Server" +msgstr "Servidor MCP" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key +msgid "Mcp Server Key" +msgstr "Chave do servidor MCP" + +#. module: ai_oca_mcp +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_act_window +#: model:ir.actions.act_window,name:ai_oca_mcp.mcp_server_log_server_filter_act_window +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_log +msgid "Mcp Server Log" +msgstr "Log do servidor MCP" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__name +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__name +msgid "Name" +msgstr "Nome" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Name..." +msgstr "Nome..." + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__request +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Request" +msgstr "Requisição" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__response +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_log_form_view +msgid "Response" +msgstr "Resposta" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__server_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_id +msgid "Server" +msgstr "Servidor" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_log__server_key_id +msgid "Server Key" +msgstr "Chave do servidor" + +#. module: ai_oca_mcp +#: model_terms:ir.ui.view,arch_db:ai_oca_mcp.mcp_server_form_view +msgid "Show logs" +msgstr "Mostrar logs" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__state +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__state +msgid "State" +msgstr "Estado" + +#. module: ai_oca_mcp +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_key_uniq +#: model:ir.model.constraint,message:ai_oca_mcp.constraint_mcp_server_key_uniq +msgid "The key must be unique" +msgstr "A chave deve ser única" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__tool_ids +msgid "Tools" +msgstr "Ferramentas" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server__url +msgid "Url" +msgstr "URL" + +#. module: ai_oca_mcp +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key__user_id +#: model:ir.model.fields,field_description:ai_oca_mcp.field_mcp_server_key_add__user_id +msgid "User" +msgstr "Usuário" + +#. module: ai_oca_mcp +#: model:ir.model,name:ai_oca_mcp.model_mcp_server_key_add +msgid "Wizard used to add a new key to an MCP Server" +msgstr "Assistente usado para adicionar uma nova chave a um servidor MCP" diff --git a/ai_oca_mcp/models/__init__.py b/ai_oca_mcp/models/__init__.py new file mode 100644 index 00000000..9370d8c8 --- /dev/null +++ b/ai_oca_mcp/models/__init__.py @@ -0,0 +1,3 @@ +from . import mcp_server +from . import mcp_server_key +from . import mcp_server_log diff --git a/ai_oca_mcp/models/mcp_server.py b/ai_oca_mcp/models/mcp_server.py new file mode 100644 index 00000000..6c5698a7 --- /dev/null +++ b/ai_oca_mcp/models/mcp_server.py @@ -0,0 +1,57 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from uuid import uuid4 + +from odoo import api, fields, models + + +class McpServer(models.Model): + _name = "mcp.server" + _description = "Mcp Server" + + name = fields.Char() + description = fields.Text() + instructions = fields.Text( + help="Extra guidance appended to the capability summary returned to" + " MCP clients in the initialize handshake." + ) + active = fields.Boolean(default=True) + key = fields.Char( + required=True, + copy=False, + default=lambda self: uuid4(), + groups="base.group_system", + ) + tool_ids = fields.Many2many( + "ai.tool", string="Tools", domain=[("kind", "=", "generic")] + ) + url = fields.Char( + compute="_compute_url", + groups="base.group_system", + ) + key_ids = fields.One2many("mcp.server.key", "server_id", string="Access Keys") + + _key_uniq = models.Constraint("unique (key)", "The key must be unique") + + @api.depends("key") + def _compute_url(self): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + for record in self: + record.url = f"{base_url}/mcp/{record.key}" + + def _get_instructions(self): + self.ensure_one() + lines = [ + "This MCP server exposes Odoo AI tools. Calls execute with the" + " permissions of the user bound to the API key.", + ] + if self.tool_ids: + lines.append("") + lines.append("Available tools:") + for tool in self.tool_ids: + suffix = f": {tool.description}" if tool.description else "" + lines.append(f"- {tool.name}{suffix}") + if self.instructions: + lines += ["", self.instructions] + return "\n".join(lines) diff --git a/ai_oca_mcp/models/mcp_server_key.py b/ai_oca_mcp/models/mcp_server_key.py new file mode 100644 index 00000000..36ce4839 --- /dev/null +++ b/ai_oca_mcp/models/mcp_server_key.py @@ -0,0 +1,102 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from hashlib import sha256 + +from odoo import api, fields, models, tools + + +class McpServerKey(models.Model): + _name = "mcp.server.key" + _description = "Mcp Server Key" + + name = fields.Char(required=True) + server_id = fields.Many2one("mcp.server", required=True, ondelete="cascade") + hashed_key = fields.Char(copy=False) + state = fields.Selection( + [("active", "Active"), ("expired", "Expired")], default="active" + ) + user_id = fields.Many2one("res.users", default=lambda self: self.env.user) + expiration_date = fields.Datetime() + expired_on = fields.Datetime(readonly=True) + + def expire_key(self): + self.filtered(lambda key: key.state == "active").write( + { + "state": "expired", + "expired_on": fields.Datetime.now(), + } + ) + self.env.registry.clear_cache() + + _key_uniq = models.Constraint("unique (hashed_key)", "The key must be unique") + + @api.model + def _hash_key(self, key): + return sha256(key.encode()).hexdigest() + + @tools.ormcache("key", "security_key") + def _get_mcp_server_by_key(self, key, security_key): + key = self.sudo().search( + [ + ("server_id.key", "=", key), + ("server_id.active", "=", True), + ("hashed_key", "=", self._hash_key(security_key)), + "|", + ("expiration_date", "=", False), + ("expiration_date", ">", fields.Datetime.now()), + ("state", "=", "active"), + ], + limit=1, + ) + return (key.id, key.expiration_date) if key else (False, False) + + def _tools_list(self, payload): + tools = self.server_id.tool_ids + result = [] + for tool in tools: + result.append(tool._get_tool_definition()) + return {"jsonrpc": "2.0", "id": payload.get("id"), "result": {"tools": result}} + + def _tools_call(self, payload): + params = payload.get("params", {}) + tool_name = params.get("name") + args = params.get("arguments", {}) + result_vals = { + "request": json.dumps(params), + "server_id": self.server_id.id, + "server_key_id": self.id, + } + + tool = self.server_id.tool_ids.filtered(lambda t: t.name == tool_name) + + if not tool: + self._add_log(**result_vals, error="Tool not found") + return { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32000, "message": "Unknown tool"}, + } + try: + with self.env.cr.savepoint(flush=False): + result = tool._execute_tool(**args) or {} + self._add_log(**result_vals, response=json.dumps(result)) + return { + "jsonrpc": "2.0", + "id": payload.get("id"), + "result": { + "structuredContent": result, + "content": [{"type": "text", "text": json.dumps(result)}], + }, + } + except Exception as e: + self._add_log(**result_vals, error=str(e)) + return { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32000, "message": str(e)}, + } + + def _add_log(self, **log_vals): + return self.env["mcp.server.log"].sudo().create(log_vals) diff --git a/ai_oca_mcp/models/mcp_server_log.py b/ai_oca_mcp/models/mcp_server_log.py new file mode 100644 index 00000000..5bd6103a --- /dev/null +++ b/ai_oca_mcp/models/mcp_server_log.py @@ -0,0 +1,16 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class McpServerLog(models.Model): + _name = "mcp.server.log" + _description = "Mcp Server Log" + + server_id = fields.Many2one("mcp.server", required=True, ondelete="cascade") + server_key_id = fields.Many2one("mcp.server.key", required=True, ondelete="cascade") + request = fields.Text() + response = fields.Text() + error = fields.Text() + date = fields.Datetime(default=fields.Datetime.now, index=True) diff --git a/ai_oca_mcp/pyproject.toml b/ai_oca_mcp/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_oca_mcp/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_oca_mcp/readme/CONTRIBUTORS.md b/ai_oca_mcp/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..ef7aa6da --- /dev/null +++ b/ai_oca_mcp/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella +- Pierre Verkest +- Angel Moya +- Daniel Reis diff --git a/ai_oca_mcp/readme/DESCRIPTION.md b/ai_oca_mcp/readme/DESCRIPTION.md new file mode 100644 index 00000000..895b0ee6 --- /dev/null +++ b/ai_oca_mcp/readme/DESCRIPTION.md @@ -0,0 +1,28 @@ +Expose Odoo as an MCP (Model Context Protocol) server, allowing external AI +clients — such as n8n, custom agents, or any MCP-compatible tool — to invoke +Odoo functions through the standardized MCP protocol. + +Each MCP server publishes a set of **AI tools** (defined by the `ai_tool` +module). Clients exchange JSON-RPC 2.0 messages with Odoo over HTTP POST: +`tools/list` returns the tool catalogue and `tools/call` executes a tool. + +Key characteristics: + +- **Authentication**: per-client API keys sent as Bearer tokens. Keys are stored + hashed and are shown only once, at creation. +- **Per-user permissions**: every key is bound to an Odoo user and calls execute + with that user's permissions — a client can only do what its key's user can + do. +- **Key lifecycle**: keys can carry an expiration date or be expired manually at + any time. +- **Audit log**: every tool invocation is recorded with its parameters and + result or error. + +Notes: + +- Transport is plain JSON-RPC over HTTP POST. SSE/streaming transports and + notifications are not supported. +- Claude Desktop requires OAuth 2.0 authorization. Odoo does not act as an + OAuth authorization server, so clients that mandate OAuth need an extension + (for example, validating externally-issued tokens via `auth_jwt` from + OCA/server-auth). diff --git a/ai_oca_mcp/readme/USAGE.md b/ai_oca_mcp/readme/USAGE.md new file mode 100644 index 00000000..52ea580f --- /dev/null +++ b/ai_oca_mcp/readme/USAGE.md @@ -0,0 +1,67 @@ +## Configure an MCP server + +1. Activate developer mode and go to `Settings > Technical > AI > MCP Server`. +2. Create a server, give it a name, and select the `generic` tools you want to + expose. +3. The `URL` field shows the client endpoint — it already includes the server's + unique path key. +4. Optionally fill `Instructions`: the `initialize` handshake always returns an + auto-generated capability summary (the server's purpose and its tool list); + this field appends extra guidance to it — domain context, conventions, which + tools to prefer. Per-tool `description` and input schemas remain the primary + channel agents rely on, so write them as if they were documentation for the + model. + +## Create a client key + +1. Click **Add Key** on the server form. +2. Choose the user the client will act as and, optionally, an expiration date. +3. Copy the generated key — it is stored hashed and **shown only once**. + +Configure your client with: + +- **URL**: the server's `URL` field +- **Authentication**: Bearer token +- **Token**: the generated API key + +### Connecting from n8n + +Use the *MCP Client* node with: + +- **Endpoint**: the server's `URL` field +- **Authentication**: Bearer Token +- **Token**: the generated API key +- **Transport**: HTTP — the endpoint expects POST requests; SSE is not supported + +## Tool limitations + +Only tools of kind `generic` are exposed. Tools requiring a record context +(`generic_model`, `record`) cannot be called via MCP. + +`ai_tool` itself ships only demo tools (`get_date`, `post_message`), of which +just `get_date` is callable via MCP. Generic read access to Odoo data — +listing models, describing fields and `search_read` — is provided by the +`ai_tool_read` addon, which also adds a per-field AI opt-out for sensitive data. Other capabilities — creating documents, +triggering actions — come from extension modules that define their own +`@aitool`-decorated methods and `ai.tool` records (see the `ai_tool` +documentation). Whatever a tool can do is limited by the permissions of the +user bound to the API key. + +Tools are not limited to reading data: a tool method runs real ORM code and can +also create or update records and call actions, always within the key owner's +permissions. Results must be JSON-serializable — return plain dicts and convert +dates, recordsets and binary values yourself — otherwise the call is logged and +returns a JSON-RPC error. + +## Security and auditing + +- Create one key per client, so a compromised client can be revoked without + affecting the others. +- Calls execute with the key owner's permissions: prefer a dedicated user with + minimal access rights to limit what clients can reach. +- Internal users can view their own keys and the related servers, but only + administrators can create or modify servers and keys. +- Keys can be expired manually from the server form, or automatically once they + reach their expiration date. +- `AI > MCP Server Log` records every tool invocation with its parameters and + result or error — use it to audit client activity. diff --git a/ai_oca_mcp/security/ir.model.access.csv b/ai_oca_mcp/security/ir.model.access.csv new file mode 100644 index 00000000..a5a53a0f --- /dev/null +++ b/ai_oca_mcp/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mcp_server,access_mcp_server,model_mcp_server,base.group_system,1,1,1,1 +access_mcp_server_key,access_mcp_server_key,model_mcp_server_key,base.group_system,1,1,1,1 +access_mcp_server_user,access_mcp_server_user,model_mcp_server,base.group_user,1,0,0,0 +access_mcp_server_key_user,access_mcp_server_key_user,model_mcp_server_key,base.group_user,1,0,0,0 +access_mcp_server_key_add,access_mcp_server_key_add,model_mcp_server_key_add,base.group_system,1,1,1,1 +access_mcp_server_log,access_mcp_server_log,model_mcp_server_log,base.group_system,1,0,0,0 diff --git a/ai_oca_mcp/security/security.xml b/ai_oca_mcp/security/security.xml new file mode 100644 index 00000000..01c95faf --- /dev/null +++ b/ai_oca_mcp/security/security.xml @@ -0,0 +1,27 @@ + + + + Mcp Server Key: own keys + + [('user_id', '=', user.id)] + + + + Mcp Server Key: all (admin) + + [(1, '=', 1)] + + + + Mcp Server: servers of own keys + + [('key_ids.user_id', '=', user.id)] + + + + Mcp Server: all (admin) + + [(1, '=', 1)] + + + diff --git a/ai_oca_mcp/static/description/icon.png b/ai_oca_mcp/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/ai_oca_mcp/static/description/icon.png differ diff --git a/ai_oca_mcp/static/description/index.html b/ai_oca_mcp/static/description/index.html new file mode 100644 index 00000000..d9b33e0c --- /dev/null +++ b/ai_oca_mcp/static/description/index.html @@ -0,0 +1,552 @@ + + + + + +Ai Oca Mcp + + + +
+ + + +Odoo Community Association + +
+

Ai Oca Mcp

+ +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

Expose Odoo as an MCP (Model Context Protocol) server, allowing external +AI clients — such as n8n, custom agents, or any MCP-compatible tool — to +invoke Odoo functions through the standardized MCP protocol.

+

Each MCP server publishes a set of AI tools (defined by the +ai_tool module). Clients exchange JSON-RPC 2.0 messages with Odoo +over HTTP POST: tools/list returns the tool catalogue and +tools/call executes a tool.

+

Key characteristics:

+
    +
  • Authentication: per-client API keys sent as Bearer tokens. Keys +are stored hashed and are shown only once, at creation.
  • +
  • Per-user permissions: every key is bound to an Odoo user and calls +execute with that user’s permissions — a client can only do what its +key’s user can do.
  • +
  • Key lifecycle: keys can carry an expiration date or be expired +manually at any time.
  • +
  • Audit log: every tool invocation is recorded with its parameters +and result or error.
  • +
+

Notes:

+
    +
  • Transport is plain JSON-RPC over HTTP POST. SSE/streaming transports +and notifications are not supported.
  • +
  • Claude Desktop requires OAuth 2.0 authorization. Odoo does not act as +an OAuth authorization server, so clients that mandate OAuth need an +extension (for example, validating externally-issued tokens via +auth_jwt from OCA/server-auth).
  • +
+

Table of contents

+ +
+

Usage

+
+

Configure an MCP server

+
    +
  1. Activate developer mode and go to +Settings > Technical > AI > MCP Server.
  2. +
  3. Create a server, give it a name, and select the generic tools you +want to expose.
  4. +
  5. The URL field shows the client endpoint — it already includes the +server’s unique path key.
  6. +
  7. Optionally fill Instructions: the initialize handshake always +returns an auto-generated capability summary (the server’s purpose +and its tool list); this field appends extra guidance to it — domain +context, conventions, which tools to prefer. Per-tool description +and input schemas remain the primary channel agents rely on, so write +them as if they were documentation for the model.
  8. +
+
+
+

Create a client key

+
    +
  1. Click Add Key on the server form.
  2. +
  3. Choose the user the client will act as and, optionally, an expiration +date.
  4. +
  5. Copy the generated key — it is stored hashed and shown only once.
  6. +
+

Configure your client with:

+
    +
  • URL: the server’s URL field
  • +
  • Authentication: Bearer token
  • +
  • Token: the generated API key
  • +
+
+

Connecting from n8n

+

Use the MCP Client node with:

+
    +
  • Endpoint: the server’s URL field
  • +
  • Authentication: Bearer Token
  • +
  • Token: the generated API key
  • +
  • Transport: HTTP — the endpoint expects POST requests; SSE is not +supported
  • +
+
+
+
+

Tool limitations

+

Only tools of kind generic are exposed. Tools requiring a record +context (generic_model, record) cannot be called via MCP.

+

ai_tool itself ships only demo tools (get_date, +post_message), of which just get_date is callable via MCP. +Generic read access to Odoo data — listing models, describing fields and +search_read — is provided by the ai_tool_read addon, which also +adds a per-field AI opt-out for sensitive data. Other capabilities — +creating documents, triggering actions — come from extension modules +that define their own @aitool-decorated methods and ai.tool +records (see the ai_tool documentation). Whatever a tool can do is +limited by the permissions of the user bound to the API key.

+

Tools are not limited to reading data: a tool method runs real ORM code +and can also create or update records and call actions, always within +the key owner’s permissions. Results must be JSON-serializable — return +plain dicts and convert dates, recordsets and binary values yourself — +otherwise the call is logged and returns a JSON-RPC error.

+
+
+

Security and auditing

+
    +
  • Create one key per client, so a compromised client can be revoked +without affecting the others.
  • +
  • Calls execute with the key owner’s permissions: prefer a dedicated +user with minimal access rights to limit what clients can reach.
  • +
  • Internal users can view their own keys and the related servers, but +only administrators can create or modify servers and keys.
  • +
  • Keys can be expired manually from the server form, or automatically +once they reach their expiration date.
  • +
  • AI > MCP Server Log records every tool invocation with its +parameters and result or error — use it to audit client activity.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/ai_oca_mcp/tests/__init__.py b/ai_oca_mcp/tests/__init__.py new file mode 100644 index 00000000..cb03ee12 --- /dev/null +++ b/ai_oca_mcp/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mcp diff --git a/ai_oca_mcp/tests/test_mcp.py b/ai_oca_mcp/tests/test_mcp.py new file mode 100644 index 00000000..26c0e80c --- /dev/null +++ b/ai_oca_mcp/tests/test_mcp.py @@ -0,0 +1,406 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from freezegun import freeze_time + +from odoo.exceptions import AccessError +from odoo.tests.common import HttpCase +from odoo.tools import mute_logger + + +class TestMcp(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.server = cls.env["mcp.server"].create( + { + "name": "Test Server", + "tool_ids": [(4, cls.env.ref("ai_tool.current_date").id)], + } + ) + wizard = ( + cls.env["mcp.server.key.add"] + .with_context(default_server_id=cls.server.id) + .create({"name": "Test Key"}) + ) + wizard.generate_key() + cls.security_key = wizard.key + + @mute_logger("odoo.http") + def test_no_authorization(self): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(request.status_code, 401) + + def test_wrong_authorization(self): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer wrong", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("error", response) + + def test_wrong_method(self): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "wrong_method", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("error", response) + + def test_correct_initialize(self): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("result", response) + self.assertIn("capabilities", response["result"]) + self.assertIn("tools", response["result"]["capabilities"]) + # instructions always carry the auto-generated capability summary + instructions = response["result"]["instructions"] + self.assertIn("Available tools:", instructions) + self.assertIn("get_date", instructions) + + def test_initialize_with_instructions(self): + self.server.instructions = "Use the search tools to query data." + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + instructions = response["result"]["instructions"] + self.assertIn("get_date", instructions) + self.assertTrue(instructions.endswith("Use the search tools to query data.")) + + def test_list_tools(self): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/list", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("result", response) + self.assertIn("tools", response["result"]) + self.assertEqual(1, len(response["result"]["tools"])) + self.assertEqual("get_date", response["result"]["tools"][0]["name"]) + + def test_execute_wrong_tool(self): + with freeze_time("2024-01-01"): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "post_message", + "arguments": {}, + }, + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("error", response) + + def test_execute_tool(self): + with freeze_time("2024-01-01"): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "get_date", + "arguments": {}, + }, + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("result", response) + self.assertIn("structuredContent", response["result"]) + self.assertEqual(response["result"]["structuredContent"]["date"], "2024-01-01") + + @mute_logger("odoo.models", "odoo.addons.base.models.ir_rule") + def test_internal_user_access(self): + """A plain internal user can read the key (and its server) it owns, + but cannot open records for which it has no key of its own.""" + group_user = self.env.ref("base.group_user") + user_a = self.env["res.users"].create( + { + "name": "MCP User A", + "login": "mcp_user_a", + "group_ids": [(6, 0, [group_user.id])], + } + ) + user_b = self.env["res.users"].create( + { + "name": "MCP User B", + "login": "mcp_user_b", + "group_ids": [(6, 0, [group_user.id])], + } + ) + self.assertFalse(user_a.has_group("base.group_system")) + # user_a owns a key on self.server + key_a = self.env["mcp.server.key"].create( + {"name": "Key A", "server_id": self.server.id, "user_id": user_a.id} + ) + # user_b owns a key on another server; user_a has no key anywhere on it + other_server = self.env["mcp.server"].create({"name": "Other Server"}) + key_b = self.env["mcp.server.key"].create( + {"name": "Key B", "server_id": other_server.id, "user_id": user_b.id} + ) + + # --- With a key of its own, the internal user can read it and its server --- + self.assertEqual(key_a.with_user(user_a).read(["name"])[0]["name"], "Key A") + self.assertEqual( + self.server.with_user(user_a).read(["name"])[0]["name"], "Test Server" + ) + self.assertIn(key_a, self.env["mcp.server.key"].with_user(user_a).search([])) + self.assertIn(self.server, self.env["mcp.server"].with_user(user_a).search([])) + + # --- Records it has no key for are filtered out and cannot be opened --- + self.assertNotIn(key_b, self.env["mcp.server.key"].with_user(user_a).search([])) + self.assertNotIn( + other_server, self.env["mcp.server"].with_user(user_a).search([]) + ) + with self.assertRaises(AccessError): + key_b.with_user(user_a).read(["name"]) + with self.assertRaises(AccessError): + other_server.with_user(user_a).read(["name"]) + + def _create_non_admin_key(self): + """Create an active key on self.server owned by a plain internal user + (base.group_user only) and return its security key.""" + user = self.env["res.users"].create( + { + "name": "MCP Plain User", + "login": "mcp_plain_user", + "group_ids": [(6, 0, [self.env.ref("base.group_user").id])], + } + ) + self.assertFalse(user.has_group("base.group_system")) + key = self.env["mcp.server.key"].create( + {"name": "Plain Key", "server_id": self.server.id, "user_id": user.id} + ) + security_key = "plain-user-security-key" + key.hashed_key = key._hash_key(security_key) + # Refresh the cached key lookup so the new key is resolvable. + self.env.registry.clear_cache() + return security_key + + def test_list_tools_non_admin_user(self): + """A key owned by a plain internal user (no ir.model access) must still + be able to list tools -- building the tool definitions must not require + Administration/Access Rights.""" + security_key = self._create_non_admin_key() + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/list", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertNotIn("error", response) + self.assertIn("result", response) + self.assertEqual(1, len(response["result"]["tools"])) + self.assertEqual("get_date", response["result"]["tools"][0]["name"]) + + def test_execute_tool_non_admin_user(self): + """A generic tool must be callable through a plain internal user's key + (its model dispatch must not require ir.model read access).""" + security_key = self._create_non_admin_key() + with freeze_time("2024-01-01"): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "get_date", + "arguments": {}, + }, + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertNotIn("error", response) + self.assertIn("result", response) + self.assertEqual(response["result"]["structuredContent"]["date"], "2024-01-01") + + def test_execute_tool_returns_error_when_tool_execution_raises_exception(self): + from unittest.mock import patch + + with patch( + "odoo.addons.ai_tool.models.ai_tool.AiTool._execute_tool", + side_effect=Exception("Test execution error"), + ): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "get_date", + "arguments": {}, + }, + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200) + response = json.loads(request.content.decode("utf-8")) + self.assertNotIn("result", response) + self.assertIn("error", response) + self.assertEqual(response["error"]["message"], "Test execution error") + + log = self.env["mcp.server.log"].search( + [("server_id", "=", self.server.id)], order="id desc", limit=1 + ) + self.assertTrue(log) + self.assertEqual(log.error, "Test execution error") + + def test_url(self): + self.server.key = "newkey" + self.assertEqual(self.server.url, f"{self.base_url()}/mcp/newkey") + + def test_expiration_handling(self): + self.server.key_ids.write({"expiration_date": "2024-01-02 00:00:00"}) + with freeze_time("2024-01-01"): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200, request.content.decode("utf-8")) + response = json.loads(request.content.decode("utf-8")) + self.assertIn("result", response) + self.assertNotIn("error", response) + with freeze_time("2024-01-03"): + request = self.url_open( + f"/mcp/{self.server.key}", + data=json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "initialize", + } + ), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.security_key}", + }, + ) + self.assertEqual(request.status_code, 200, request.content.decode("utf-8")) + response = json.loads(request.content.decode("utf-8")) + self.assertNotIn("result", response) + self.assertIn("error", response) diff --git a/ai_oca_mcp/views/mcp_server.xml b/ai_oca_mcp/views/mcp_server.xml new file mode 100644 index 00000000..fdb3222c --- /dev/null +++ b/ai_oca_mcp/views/mcp_server.xml @@ -0,0 +1,90 @@ + + + + + mcp.server + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + +
+ + + + + + mcp.server + + + + + + + + + mcp.server + + + + + + + + + MCP Server + mcp.server + list,form + [] + {} + + + + MCP Server + + + + + diff --git a/ai_oca_mcp/views/mcp_server_key.xml b/ai_oca_mcp/views/mcp_server_key.xml new file mode 100644 index 00000000..9c2506e4 --- /dev/null +++ b/ai_oca_mcp/views/mcp_server_key.xml @@ -0,0 +1,48 @@ + + + + + mcp.server.key + +
+
+
+ + + + + + + + + +
+
+
+ + + mcp.server.key + + + + +