diff --git a/.changeset/db-stable-1-0.md b/.changeset/db-stable-1-0.md new file mode 100644 index 0000000..7ff7d04 --- /dev/null +++ b/.changeset/db-stable-1-0.md @@ -0,0 +1,5 @@ +--- +"@unclecat/mcp-multi-db": major +--- + +1.0.0 marks the stable API. Nothing changes for you since 0.0.8: same tools, arguments, config file and env vars. From now on releases follow [semver](https://github.com/unclecatvn/mcp/blob/master/CONTRIBUTING.md#versioning) strictly, so a removed or renamed tool, argument, error code or env var, or a changed default, only ships in a new major. Install with `npx -y @unclecat/mcp-multi-db@1` to get every 1.x fix and feature without breaking changes. diff --git a/.changeset/obsidian-stable-1-0.md b/.changeset/obsidian-stable-1-0.md new file mode 100644 index 0000000..179ed83 --- /dev/null +++ b/.changeset/obsidian-stable-1-0.md @@ -0,0 +1,5 @@ +--- +"@unclecat/mcp-obsidian": major +--- + +1.0.0 marks the stable API. Nothing changes for you since 0.0.2: same tools, arguments and env vars. From now on releases follow [semver](https://github.com/unclecatvn/mcp/blob/master/CONTRIBUTING.md#versioning) strictly, so a removed or renamed tool, argument, error code or env var, or a changed default, only ships in a new major. Install with `npx -y @unclecat/mcp-obsidian@1` (instead of `@latest`) to get every 1.x fix and feature without breaking changes. diff --git a/.changeset/odoo-review-cleanup.md b/.changeset/odoo-review-cleanup.md new file mode 100644 index 0000000..1b10060 --- /dev/null +++ b/.changeset/odoo-review-cleanup.md @@ -0,0 +1,13 @@ +--- +"@unclecat/mcp-odoo": major +--- + +Breaking: `ODOO_INPUT_INVALID` is gone (validation errors now read `Input validation error: ...`) and `search_read` defaults to `limit: 100`. + +- Fix `name_search` with a `domain` on Odoo 19 (`unexpected keyword argument 'args'`): name and domain are now sent positionally, which works on 18 and 19. +- Fix the request timeout not covering the response body read; a stalled body now fails with `ODOO_TRANSPORT_FAILED` instead of hanging. +- `search_read` defaults to `limit: 100` and returns `limit_reached: true` when a page is full. +- Tools carry MCP annotations (`readOnlyHint` on reads, `destructiveHint` on `write` / `unlink` / `call_method`). +- Move to `McpServer.registerTool` with zod schemas (single source of truth); unknown arguments are still rejected. Validation failures now read `Input validation error: ...` instead of `[ODOO_INPUT_INVALID]`. +- Trim tool descriptions and instructions (~5.8k → ~3.3k tokens per session); instructions now fit under the ~2 KB hosts truncate at. +- Remove the unused `index.d.ts` (it declared exports `index.js` never had). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af7183d..89ef22f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,7 @@ Packages follow [semantic versioning](https://semver.org), so users can pin a ma If a PR changes shipped code but has no changeset, the release workflow adds a `patch` changeset from the commit subject, so Dependabot updates still get released. Breaking changes always need a hand-written `major` changeset. -All packages are still `0.0.x`. `1.0.0` is planned together with the Odoo refactor, and from then on the table above applies strictly. +Every package reached `1.0.0` together with the Odoo refactor, and the table above now applies strictly: anything listed under `major` waits for the next major version, and the READMEs pin installs to the current major (`@1`). ## Releasing manually (maintainer) diff --git a/README.md b/README.md index 8f4aef2..d10d1cf 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,15 @@ Each package has its own README with full configuration, a tool reference and tr Every server runs through `npx`, so there is nothing to install or build. With [Claude Code](https://docs.anthropic.com/en/docs/claude-code): ```bash -claude mcp add odoo -e ODOO_PROD_URL=https://erp.example.com -e ODOO_PROD_DB=production -e ODOO_PROD_USERNAME=admin -e ODOO_PROD_API_KEY=your-api-key -- npx -y @unclecat/mcp-odoo +claude mcp add odoo -e ODOO_PROD_URL=https://erp.example.com -e ODOO_PROD_DB=production -e ODOO_PROD_USERNAME=admin -e ODOO_PROD_API_KEY=your-api-key -- npx -y @unclecat/mcp-odoo@1 ``` ```bash -claude mcp add obsidian -e OBSIDIAN_MAIN_PATH=/absolute/path/to/Vault -- npx -y @unclecat/mcp-obsidian +claude mcp add obsidian -e OBSIDIAN_MAIN_PATH=/absolute/path/to/Vault -- npx -y @unclecat/mcp-obsidian@1 ``` ```bash -claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- npx -y @unclecat/mcp-multi-db +claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- npx -y @unclecat/mcp-multi-db@1 ``` For Claude Desktop, Cursor and VS Code, see the configuration section of each package README. diff --git a/db/README.md b/db/README.md index a94aaac..b65f6ca 100644 --- a/db/README.md +++ b/db/README.md @@ -79,7 +79,7 @@ The `$schema` line gives you autocompletion and validation in VS Code and JetBra **Claude Code** ```bash -claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- npx -y @unclecat/mcp-multi-db +claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- npx -y @unclecat/mcp-multi-db@1 ``` **Claude Desktop, Cursor, Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json` or `.mcp.json`) @@ -89,7 +89,7 @@ claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- "mcpServers": { "multi-db": { "command": "npx", - "args": ["-y", "@unclecat/mcp-multi-db"], + "args": ["-y", "@unclecat/mcp-multi-db@1"], "env": { "MCP_DB_CONFIG": "/absolute/path/to/mcp-db.config.json" } } } @@ -104,7 +104,7 @@ claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- "multi-db": { "type": "stdio", "command": "npx", - "args": ["-y", "@unclecat/mcp-multi-db"], + "args": ["-y", "@unclecat/mcp-multi-db@1"], "env": { "MCP_DB_CONFIG": "${workspaceFolder}/mcp-db.config.json" } } } diff --git a/db/README.vi.md b/db/README.vi.md index 5967753..69d6033 100644 --- a/db/README.vi.md +++ b/db/README.vi.md @@ -79,7 +79,7 @@ Dòng `$schema` giúp bạn có autocomplete và validation trong VS Code và c **Claude Code** ```bash -claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- npx -y @unclecat/mcp-multi-db +claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- npx -y @unclecat/mcp-multi-db@1 ``` **Claude Desktop, Cursor, Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json` hoặc `.mcp.json`) @@ -89,7 +89,7 @@ claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- "mcpServers": { "multi-db": { "command": "npx", - "args": ["-y", "@unclecat/mcp-multi-db"], + "args": ["-y", "@unclecat/mcp-multi-db@1"], "env": { "MCP_DB_CONFIG": "/absolute/path/to/mcp-db.config.json" } } } @@ -104,7 +104,7 @@ claude mcp add multi-db -e MCP_DB_CONFIG=/absolute/path/to/mcp-db.config.json -- "multi-db": { "type": "stdio", "command": "npx", - "args": ["-y", "@unclecat/mcp-multi-db"], + "args": ["-y", "@unclecat/mcp-multi-db@1"], "env": { "MCP_DB_CONFIG": "${workspaceFolder}/mcp-db.config.json" } } } diff --git a/obsidian/README.md b/obsidian/README.md index 6d47956..8dec1ac 100644 --- a/obsidian/README.md +++ b/obsidian/README.md @@ -34,10 +34,10 @@ The server works on the `.md` files in your vault folder. Obsidian notices chang claude mcp add obsidian -s user \ -e OBSIDIAN_MAIN_PATH="/absolute/path/to/Vault" \ -e OBSIDIAN_MAIN_MODE=readwrite \ - -- npx -y @unclecat/mcp-obsidian@latest + -- npx -y @unclecat/mcp-obsidian@1 ``` -`@latest` makes `npx` check for a new release every time the server starts. +`@1` makes `npx` pick up every new 1.x release when the server starts, and never a breaking 2.0. ### Claude Desktop, Cursor, Windsurf @@ -48,7 +48,7 @@ Add the server to `claude_desktop_config.json`, `.cursor/mcp.json` or the projec "mcpServers": { "obsidian": { "command": "npx", - "args": ["-y", "@unclecat/mcp-obsidian@latest"], + "args": ["-y", "@unclecat/mcp-obsidian@1"], "env": { "OBSIDIAN_MAIN_PATH": "/absolute/path/to/Vault", "OBSIDIAN_MAIN_MODE": "readwrite" @@ -68,7 +68,7 @@ In `.vscode/mcp.json` (VS Code 1.102+), the top-level key is `servers`: "obsidian": { "type": "stdio", "command": "npx", - "args": ["-y", "@unclecat/mcp-obsidian@latest"], + "args": ["-y", "@unclecat/mcp-obsidian@1"], "env": { "OBSIDIAN_MAIN_PATH": "/absolute/path/to/Vault" } } } diff --git a/obsidian/README.vi.md b/obsidian/README.vi.md index 5f12d7b..7c6b66b 100644 --- a/obsidian/README.vi.md +++ b/obsidian/README.vi.md @@ -34,10 +34,10 @@ Server làm việc trên các file `.md` trong thư mục vault của bạn. Obs claude mcp add obsidian -s user \ -e OBSIDIAN_MAIN_PATH="/absolute/path/to/Vault" \ -e OBSIDIAN_MAIN_MODE=readwrite \ - -- npx -y @unclecat/mcp-obsidian@latest + -- npx -y @unclecat/mcp-obsidian@1 ``` -`@latest` khiến `npx` kiểm tra bản phát hành mới mỗi lần server khởi động. +`@1` khiến `npx` tự lấy bản 1.x mới nhất mỗi lần server khởi động, và không bao giờ nhảy sang bản 2.0 có thay đổi không tương thích. ### Claude Desktop, Cursor, Windsurf @@ -48,7 +48,7 @@ Thêm server vào `claude_desktop_config.json`, `.cursor/mcp.json` hoặc `.mcp. "mcpServers": { "obsidian": { "command": "npx", - "args": ["-y", "@unclecat/mcp-obsidian@latest"], + "args": ["-y", "@unclecat/mcp-obsidian@1"], "env": { "OBSIDIAN_MAIN_PATH": "/absolute/path/to/Vault", "OBSIDIAN_MAIN_MODE": "readwrite" @@ -68,7 +68,7 @@ Trong `.vscode/mcp.json` (VS Code 1.102+), key cấp cao nhất là `servers`: "obsidian": { "type": "stdio", "command": "npx", - "args": ["-y", "@unclecat/mcp-obsidian@latest"], + "args": ["-y", "@unclecat/mcp-obsidian@1"], "env": { "OBSIDIAN_MAIN_PATH": "/absolute/path/to/Vault" } } } diff --git a/odoo/CLAUDE.md b/odoo/CLAUDE.md index 5d8fd98..f0b6efd 100644 --- a/odoo/CLAUDE.md +++ b/odoo/CLAUDE.md @@ -23,43 +23,41 @@ Plain JavaScript (ESM). No build step — `bin/mcp-odoo` shells straight to `ind ### Entry-point flow -`index.js` → `OdooMCPServer` (`mcpServer.js`) → `ClientRegistry` → `OdooClient` +`index.js` → `OdooMCPServer` (`mcpServer.js`) → `registerTools(McpServer, Map)` ### Modules -| File | Responsibility | -| -------------------------- | ---------------------------------------------------------------------------------- | -| `index.js` | Shebang + spawns `OdooMCPServer.run()` | -| `mcpServer.js` | MCP wiring: parses env, registers tool handlers, sets `serverInfo.instructions` | -| `lib/config.js` | `parseEnv()` — discovers `ODOO__*` blocks; never throws on per-connection error | -| `lib/errors.js` | `McpOdooError` subclasses + `formatErrorForMcp()` for stable tool error envelopes | -| `lib/validators.js` | zod schemas for every tool's input; `parseOrThrow` turns failures into `ValidationError` | -| `lib/client.js` | `OdooClient` — JSON-RPC fetch wrapper, lazy `authenticate()` with in-flight dedup | -| `lib/clientRegistry.js` | One `OdooClient` per configured connection; resolves by name | -| `lib/toolHandlers.js` | `ToolHandlers.toolDescriptors()` + `.dispatch(name, args)` for 7 tools | -| `lib/instructions.js` | The Odoo cheatsheet sent on `initialize` | +| File | Responsibility | +| --------------------- | -------------------------------------------------------------------------------------- | +| `index.js` | Shebang + runs `OdooMCPServer.run()` | +| `mcpServer.js` | Parses env, logs to stderr, builds one `OdooClient` per connection, creates `McpServer` | +| `lib/config.js` | `parseEnv()` — discovers `ODOO__*` blocks; never throws on per-connection error | +| `lib/errors.js` | `OdooError(code, message)` (message pre-prefixed `[CODE]`) + `fromOdooError()` | +| `lib/client.js` | `OdooClient` — JSON-RPC fetch wrapper, lazy `authenticate()` with in-flight dedup | +| `lib/toolHandlers.js` | `registerTools()` — the 10 tools, their zod input schemas and annotations | +| `lib/instructions.js` | The cheatsheet sent on `initialize` (keep under 2 KB — hosts truncate it) | ### Tools registered -`list_connections`, `fields_get`, `search_read`, `create`, `write`, `unlink`, `call_method`. +Read (`readOnlyHint`): `list_connections`, `fields_get`, `search_read`, `search_count`, `name_search`, `read_group`. +Write: `create`, `write`, `unlink`, `call_method` (the last three `destructiveHint`). ### Connection configuration -`ODOO__` env vars, discovered by the `_URL` anchor. Fields: `URL`, `DB`, `USERNAME`, plus exactly one of `API_KEY` / `PASSWORD`. Bad entries are logged and skipped — server keeps running. +`ODOO__` env vars, discovered by the `_URL` anchor. Fields: `URL`, `DB`, `USERNAME`, plus exactly one of `API_KEY` / `PASSWORD`, optional `TIMEOUT_MS`. Bad entries are logged and skipped — server keeps running. ### Important conventions to preserve -- **MCP stdio**: never `console.log` — that corrupts the protocol stream. Logging goes to **stderr** via the structured logger in `mcpServer.js`. -- **Errors**: tool failures must come back through `formatErrorForMcp()` so the host sees `{ isError: true, content: [{ type: "text", text: "[CODE] message" }] }`. -- **Validation**: every tool handler validates with `parseOrThrow(schema, args, toolName)` before touching the registry. +- **MCP stdio**: never `console.log` — that corrupts the protocol stream. Logging goes to **stderr** via `this.log` in `mcpServer.js`. +- **Errors**: throw `OdooError`; the SDK turns any thrown error into `{ isError: true, content: [{ type: "text", text: err.message }] }`, and the message already starts with `[CODE]`. +- **Validation**: input schemas are `z.object({...}).strict()` so unknown keys are rejected, not silently dropped. Odoo validates model/method names itself — don't add regexes that cost schema tokens on every tool. +- **Odoo 18 vs 19 signatures**: pass parameters whose names differ between versions positionally (e.g. `name_search`'s 2nd param is `args` in 18, `domain` in 19). - **Secrets**: `list_connections` exposes `authType` but never `secret`. `OdooClient.describe()` is the single source of truth for that contract — keep it lean. - **Auth lazy + deduped**: don't call `_rpc("common","authenticate",...)` directly from handlers; always go through `client.callKw()` which guarantees one in-flight authenticate even under concurrent first calls. ### Adding a new tool -1. Add input schema to `lib/validators.js`. -2. Add a `case` and an `async _xxx(args)` method to `lib/toolHandlers.js`. -3. Add a descriptor to `toolDescriptors()` with a description that includes an example. -4. Add a test to `test/unit/toolHandlers.test.js`. +1. Add a `server.registerTool(...)` call in `lib/toolHandlers.js` with a strict zod schema, `annotations`, and a short description with one EXAMPLE. +2. Add a test to `test/unit/server.test.js` (stub client via `registerTools`). If the tool needs new behaviour on `OdooClient`, add a method there and unit-test it in `test/unit/client.test.js` with a stubbed `fetchImpl`. diff --git a/odoo/README.md b/odoo/README.md index cde0b96..3d47aa7 100644 --- a/odoo/README.md +++ b/odoo/README.md @@ -21,7 +21,7 @@ The server exposes Odoo's ORM through ten generic tools, so it works with every - a `call_method` escape hatch for business actions such as confirming orders, posting invoices or running wizards. - **Several instances.** Configure any number of instances, each with its own credentials and timeout. - **API-key auth** (recommended) or a password, chosen per instance. -- **Built-in Odoo cheatsheet.** On `initialize` the model receives notes on domain syntax, x2many command tuples, field formats and error codes, so it builds correct calls without trial and error. +- **Built-in Odoo cheatsheet.** On `initialize` the model receives a short note on the workflow, x2many command tuples, field formats and error codes. It stays under the ~2 KB that hosts such as Claude Code truncate at; domain syntax and business-action examples live in each tool's description. - **Stable error codes.** Odoo exceptions are mapped to codes such as `ODOO_ACCESS_DENIED` and `ODOO_USER_ERROR`, so the model knows whether to fix the call, retry or stop. ## Quick start @@ -36,7 +36,7 @@ claude mcp add odoo \ -e ODOO_PROD_DB=production \ -e ODOO_PROD_USERNAME=admin@example.com \ -e ODOO_PROD_API_KEY=your-api-key \ - -- npx -y @unclecat/mcp-odoo + -- npx -y @unclecat/mcp-odoo@1 ``` ### Claude Desktop, Cursor, Windsurf @@ -48,7 +48,7 @@ Add the server to `claude_desktop_config.json`, `.cursor/mcp.json` or the projec "mcpServers": { "odoo": { "command": "npx", - "args": ["-y", "@unclecat/mcp-odoo"], + "args": ["-y", "@unclecat/mcp-odoo@1"], "env": { "ODOO_PROD_URL": "https://erp.example.com", "ODOO_PROD_DB": "production", @@ -106,7 +106,7 @@ Every tool except `list_connections` takes a `connection` argument. |---|---|---| | `list_connections` | Discover the configured instances, once per session | `{ connections: [{ name, url, db, username, authType, ... }] }` | | `fields_get` | Inspect a model's fields before writing. Results are cached. | `{ model, fields: { name: { type, required, relation, ... } } }` | -| `search_read` | Query records with a domain, fields, limit and order | `{ model, count, records: [...] }` | +| `search_read` | Query records with a domain, fields, limit and order. `limit` defaults to 100; `limit_reached: true` means there may be more. | `{ model, count, limit_reached?, records: [...] }` | | `search_count` | Count records without fetching them | `{ model, count }` | | `name_search` | Fuzzy lookup by display name | `{ model, results: [[id, "name"], ...] }` | | `read_group` | Group by fields and aggregate, for reports and KPIs | `{ model, count, groups: [...] }` | @@ -128,7 +128,6 @@ Errors are returned as `[CODE] message` inside an MCP `isError` result. | Code | Meaning | What the model should do | |---|---|---| -| `ODOO_INPUT_INVALID` | Arguments failed schema validation | Fix the call | | `ODOO_UNKNOWN_CONNECTION` | Unknown `connection` | Call `list_connections` again | | `ODOO_AUTH_FAILED` | Bad credentials, or a password login for a 2FA user | Stop and ask the operator | | `ODOO_ACCESS_DENIED` | The user lacks rights on the model or record | Do not retry | @@ -138,9 +137,12 @@ Errors are returned as `[CODE] message` inside an MCP `isError` result. | `ODOO_SERVER_ERROR` | Unexpected Odoo exception | Treat as fatal for this call | | `ODOO_TRANSPORT_FAILED` | Network, HTTP or timeout error | Safe to retry once | +Arguments that fail the tool schema, including unknown keys, come back as the SDK's `Input validation error: ...` text instead of a code. + ## Security - **Least privilege.** The model can do anything the configured Odoo user can do. Connect it as a dedicated user whose access rights match what you want the assistant to touch. +- **Tool annotations.** Read tools carry `readOnlyHint`; `write`, `unlink` and `call_method` carry `destructiveHint`, so hosts can auto-approve reads and ask before writes. - **Prefer API keys.** You can revoke an API key without changing the user's password. - **Plain HTTP.** `http://` URLs are accepted but trigger a startup warning, because credentials would travel in clear text. - **No secret leaks.** `list_connections` reports the auth *type*, never the key or password. diff --git a/odoo/README.vi.md b/odoo/README.vi.md index bf11b14..a1cfa08 100644 --- a/odoo/README.vi.md +++ b/odoo/README.vi.md @@ -21,7 +21,7 @@ Server mở ORM của Odoo thông qua mười tool tổng quát, nên hoạt đ - lối thoát `call_method` cho các business action như xác nhận đơn hàng, ghi sổ hóa đơn hoặc chạy wizard. - **Nhiều instance.** Cấu hình bao nhiêu instance tùy ý, mỗi instance có thông tin đăng nhập và timeout riêng. - **Xác thực bằng API key** (khuyến nghị) hoặc mật khẩu, chọn riêng cho từng instance. -- **Cheatsheet Odoo tích hợp sẵn.** Khi `initialize`, model nhận được ghi chú về cú pháp domain, command tuple của x2many, định dạng field và mã lỗi, nên dựng được lời gọi đúng mà không cần thử sai. +- **Cheatsheet Odoo tích hợp sẵn.** Khi `initialize`, model nhận một ghi chú ngắn về workflow, command tuple của x2many, định dạng field và mã lỗi. Ghi chú này giữ dưới ~2 KB, ngưỡng mà các host như Claude Code cắt bớt; cú pháp domain và ví dụ business action nằm trong description của từng tool. - **Mã lỗi ổn định.** Các exception của Odoo được ánh xạ sang các mã như `ODOO_ACCESS_DENIED` và `ODOO_USER_ERROR`, để model biết nên sửa lời gọi, retry hay dừng lại. ## Bắt đầu nhanh @@ -36,7 +36,7 @@ claude mcp add odoo \ -e ODOO_PROD_DB=production \ -e ODOO_PROD_USERNAME=admin@example.com \ -e ODOO_PROD_API_KEY=your-api-key \ - -- npx -y @unclecat/mcp-odoo + -- npx -y @unclecat/mcp-odoo@1 ``` ### Claude Desktop, Cursor, Windsurf @@ -48,7 +48,7 @@ Thêm server vào `claude_desktop_config.json`, `.cursor/mcp.json` hoặc `.mcp. "mcpServers": { "odoo": { "command": "npx", - "args": ["-y", "@unclecat/mcp-odoo"], + "args": ["-y", "@unclecat/mcp-odoo@1"], "env": { "ODOO_PROD_URL": "https://erp.example.com", "ODOO_PROD_DB": "production", @@ -106,7 +106,7 @@ Mọi tool trừ `list_connections` đều nhận đối số `connection`. |---|---|---| | `list_connections` | Xem các instance đã cấu hình, một lần cho mỗi phiên | `{ connections: [{ name, url, db, username, authType, ... }] }` | | `fields_get` | Xem các field của một model trước khi ghi. Kết quả được cache. | `{ model, fields: { name: { type, required, relation, ... } } }` | -| `search_read` | Truy vấn bản ghi với domain, field, limit và order | `{ model, count, records: [...] }` | +| `search_read` | Truy vấn bản ghi với domain, field, limit và order. `limit` mặc định 100; `limit_reached: true` nghĩa là có thể còn nữa. | `{ model, count, limit_reached?, records: [...] }` | | `search_count` | Đếm bản ghi mà không cần lấy dữ liệu về | `{ model, count }` | | `name_search` | Tra cứu mờ theo tên hiển thị | `{ model, results: [[id, "name"], ...] }` | | `read_group` | Nhóm theo field và tổng hợp, cho báo cáo và KPI | `{ model, count, groups: [...] }` | @@ -128,7 +128,6 @@ Lỗi được trả về dưới dạng `[CODE] message` bên trong một kết | Mã | Ý nghĩa | Model nên làm gì | |---|---|---| -| `ODOO_INPUT_INVALID` | Đối số không qua được bước kiểm tra schema | Sửa lời gọi | | `ODOO_UNKNOWN_CONNECTION` | `connection` không xác định | Gọi lại `list_connections` | | `ODOO_AUTH_FAILED` | Sai thông tin đăng nhập, hoặc đăng nhập bằng mật khẩu với user bật 2FA | Dừng lại và hỏi người vận hành | | `ODOO_ACCESS_DENIED` | User không có quyền trên model hoặc bản ghi | Không retry | @@ -138,9 +137,12 @@ Lỗi được trả về dưới dạng `[CODE] message` bên trong một kết | `ODOO_SERVER_ERROR` | Exception không mong đợi từ Odoo | Coi là lỗi nghiêm trọng đối với lời gọi này | | `ODOO_TRANSPORT_FAILED` | Lỗi mạng, HTTP hoặc timeout | Có thể retry một lần | +Đối số sai schema, kể cả key không tồn tại, trả về chuỗi `Input validation error: ...` của SDK thay vì một mã lỗi. + ## Bảo mật - **Đặc quyền tối thiểu.** Model có thể làm mọi việc mà user Odoo đã cấu hình làm được. Hãy kết nối bằng một user riêng có quyền truy cập khớp với phạm vi bạn muốn trợ lý được đụng tới. +- **Tool annotations.** Tool đọc gắn `readOnlyHint`; `write`, `unlink` và `call_method` gắn `destructiveHint`, để host tự duyệt thao tác đọc và hỏi lại trước khi ghi. - **Ưu tiên API key.** Bạn có thể thu hồi API key mà không phải đổi mật khẩu của user. - **HTTP thuần.** URL `http://` được chấp nhận nhưng sẽ kích hoạt một cảnh báo khi khởi động, vì thông tin đăng nhập sẽ đi qua mạng dưới dạng văn bản thuần. - **Không rò rỉ secret.** `list_connections` chỉ báo *loại* xác thực, không bao giờ báo key hay mật khẩu. diff --git a/odoo/index.d.ts b/odoo/index.d.ts deleted file mode 100644 index ee2cfc9..0000000 --- a/odoo/index.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -export declare class OdooMCPServer { - constructor(options?: { env?: NodeJS.ProcessEnv | Record }); - run(): Promise; -} - -export type OdooAuthType = "apikey" | "password"; - -export interface OdooConnectionConfig { - name: string; - url: string; - db: string; - username: string; - authType: OdooAuthType; - secret: string; - timeoutMs: number; -} - -export type OdooErrorCode = - | "ODOO_CONFIG_INVALID" - | "ODOO_INPUT_INVALID" - | "ODOO_UNKNOWN_CONNECTION" - | "ODOO_AUTH_FAILED" - | "ODOO_ACCESS_DENIED" - | "ODOO_MISSING_RECORD" - | "ODOO_FIELD_INVALID" - | "ODOO_USER_ERROR" - | "ODOO_SERVER_ERROR" - | "ODOO_TRANSPORT_FAILED" - | "ODOO_INTERNAL"; diff --git a/odoo/lib/client.js b/odoo/lib/client.js index 585e979..1afd01b 100644 --- a/odoo/lib/client.js +++ b/odoo/lib/client.js @@ -1,4 +1,4 @@ -import { AuthError, TransportError, fromOdooError } from "./errors.js"; +import { OdooError, fromOdooError } from "./errors.js"; const DEFAULT_TIMEOUT_MS = 60_000; @@ -66,56 +66,33 @@ export class OdooClient { id: Math.floor(Math.random() * 1e9), }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - - let res; + let json; try { - res = await this._fetch(`${this.url}/jsonrpc`, { + const res = await this._fetch(`${this.url}/jsonrpc`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(body), - signal: controller.signal, + // Unlike a setTimeout cleared after the headers, this also bounds the body read. + signal: AbortSignal.timeout(this.timeoutMs), }); - } catch (e) { - if (e?.name === "AbortError") { - throw new TransportError( - `Odoo request timed out after ${this.timeoutMs}ms (connection=${this.name})`, - { connection: this.name, timeoutMs: this.timeoutMs }, - e, + if (!res.ok) { + throw new OdooError( + "ODOO_TRANSPORT_FAILED", + `Odoo HTTP ${res.status} ${res.statusText} (connection=${this.name})`, ); } - throw new TransportError( - `Failed to reach Odoo at ${this.url} (connection=${this.name}): ${e.message}`, - { connection: this.name, url: this.url }, - e, - ); - } finally { - clearTimeout(timer); - } - - if (!res.ok) { - throw new TransportError( - `Odoo HTTP ${res.status} ${res.statusText} (connection=${this.name})`, - { connection: this.name, status: res.status }, - ); - } - - let json; - try { json = await res.json(); } catch (e) { - throw new TransportError( - `Odoo returned non-JSON response (connection=${this.name}): ${e.message}`, - { connection: this.name }, + if (e instanceof OdooError) throw e; + const reason = e?.name === "TimeoutError" ? `timed out after ${this.timeoutMs}ms` : e.message; + throw new OdooError( + "ODOO_TRANSPORT_FAILED", + `Odoo request to ${this.url} failed (connection=${this.name}): ${reason}`, e, ); } - if (json.error) { - throw fromOdooError(json.error, { connection: this.name }); - } - + if (json.error) throw fromOdooError(json.error); return json.result; } @@ -135,11 +112,11 @@ export class OdooClient { {}, ]); if (!uid || typeof uid !== "number") { - throw new AuthError( + throw new OdooError( + "ODOO_AUTH_FAILED", `Authentication failed for connection "${this.name}" (db=${this.db}, user=${this.username}). ` + `Check the ${this.authType === "apikey" ? "API key" : "password"} and database name. ` + `Note: users with 2FA enabled cannot authenticate with their password — use an API key instead.`, - { connection: this.name, db: this.db, username: this.username, authType: this.authType }, ); } this.uid = uid; @@ -188,17 +165,6 @@ export class OdooClient { return result; } - /** Drop the fields_get cache (test helper / future invalidation hook). */ - clearFieldsCache(model) { - if (model === undefined) { - this._fieldsCache.clear(); - return; - } - for (const key of this._fieldsCache.keys()) { - if (key.startsWith(`${model}|`)) this._fieldsCache.delete(key); - } - } - async searchRead(model, { domain = [], fields, limit, offset, order } = {}) { const kwargs = omitUndefined({ domain, fields, limit, offset, order }); return this.callKw(model, "search_read", [], kwargs); @@ -208,9 +174,9 @@ export class OdooClient { return this.callKw(model, "search_count", [domain], omitUndefined({ limit })); } - async nameSearch(model, { name = "", domain, operator, limit } = {}) { - const kwargs = omitUndefined({ name, args: domain, operator, limit }); - return this.callKw(model, "name_search", [], kwargs); + async nameSearch(model, { name = "", domain = [], operator, limit } = {}) { + // Positional on purpose: the 2nd parameter is `args` in Odoo 18 but `domain` in 19. + return this.callKw(model, "name_search", [name, domain], omitUndefined({ operator, limit })); } async readGroup(model, { domain = [], aggregates, groupby, offset, limit, orderby, lazy } = {}) { diff --git a/odoo/lib/clientRegistry.js b/odoo/lib/clientRegistry.js deleted file mode 100644 index 9cb3896..0000000 --- a/odoo/lib/clientRegistry.js +++ /dev/null @@ -1,45 +0,0 @@ -import { OdooClient } from "./client.js"; -import { UnknownConnectionError } from "./errors.js"; - -/** - * Holds one OdooClient per configured connection. Clients are constructed - * eagerly (cheap — no network) but authentication is lazy on first use. - */ -export class ClientRegistry { - /** - * @param {Record} connections - * @param {object} [opts] - * @param {(typeof fetch)} [opts.fetchImpl] Override for tests. - */ - constructor(connections, { fetchImpl } = {}) { - /** @type {Map} */ - this.clients = new Map(); - for (const cfg of Object.values(connections)) { - this.clients.set(cfg.name, new OdooClient({ ...cfg, fetchImpl })); - } - } - - has(name) { - return this.clients.has(name); - } - - /** Returns names in registration order. */ - list() { - return Array.from(this.clients.values()).map((c) => c.describe()); - } - - /** Resolve a client by name or throw a user-actionable error. */ - get(name) { - const client = this.clients.get(name); - if (!client) { - const available = Array.from(this.clients.keys()); - throw new UnknownConnectionError( - available.length === 0 - ? `No Odoo connections configured. Set ODOO__URL/DB/USERNAME and either ODOO__API_KEY or ODOO__PASSWORD.` - : `Unknown connection "${name}". Available: ${available.join(", ")}`, - { requested: name, available }, - ); - } - return client; - } -} diff --git a/odoo/lib/errors.js b/odoo/lib/errors.js index da793e0..0d770e2 100644 --- a/odoo/lib/errors.js +++ b/odoo/lib/errors.js @@ -1,153 +1,30 @@ /** - * Error class hierarchy for the MCP Odoo server. - * - * Each error has a stable `code` (used by Claude to decide whether to retry, - * re-search, ask the user, etc.), a user-facing `message`, optional `details`, - * an optional underlying `cause`, and a `retryable` flag. - * - * Two layers of errors: - * - Server-side (we throw): ConfigError, InputValidationError, AuthError, - * UnknownConnectionError, TransportError. - * - Odoo-side (mapped from the JSON-RPC error envelope): OdooFieldError, - * OdooMissingRecordError, OdooUserError, OdooAccessError, OdooServerError. + * The one error type the server throws. `code` is stable so Claude can decide + * whether to retry, re-search or ask the user; the message is pre-prefixed with + * `[CODE]` so the MCP SDK's default tool-error envelope carries it verbatim. */ - -export class McpOdooError extends Error { - constructor(code, message, details = {}, cause = undefined, retryable = false) { - super(message); - this.name = "McpOdooError"; +export class OdooError extends Error { + constructor(code, message, cause = undefined) { + super(`[${code}] ${message}`, { cause }); + this.name = "OdooError"; this.code = code; - this.details = details; - this.cause = cause; - this.retryable = retryable; - } -} - -// ---- Server-side errors ---------------------------------------------------- - -export class ConfigError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_CONFIG_INVALID", message, details, cause, false); - this.name = "ConfigError"; - } -} - -export class InputValidationError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_INPUT_INVALID", message, details, cause, false); - this.name = "InputValidationError"; - } -} - -export class UnknownConnectionError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_UNKNOWN_CONNECTION", message, details, cause, false); - this.name = "UnknownConnectionError"; - } -} - -export class AuthError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_AUTH_FAILED", message, details, cause, false); - this.name = "AuthError"; - } -} - -export class TransportError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_TRANSPORT_FAILED", message, details, cause, true); - this.name = "TransportError"; - } -} - -// ---- Odoo-side errors (mapped from json.error.data.name) ------------------- - -export class OdooFieldError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_FIELD_INVALID", message, details, cause, false); - this.name = "OdooFieldError"; } } -export class OdooMissingRecordError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_MISSING_RECORD", message, details, cause, false); - this.name = "OdooMissingRecordError"; - } -} - -export class OdooUserError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_USER_ERROR", message, details, cause, false); - this.name = "OdooUserError"; - } -} - -export class OdooAccessError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_ACCESS_DENIED", message, details, cause, false); - this.name = "OdooAccessError"; - } -} - -export class OdooServerError extends McpOdooError { - constructor(message, details = {}, cause = undefined) { - super("ODOO_SERVER_ERROR", message, details, cause, false); - this.name = "OdooServerError"; - } -} - -// ---- Mapping --------------------------------------------------------------- - -const ODOO_EXCEPTION_MAP = { - "odoo.exceptions.AccessDenied": AuthError, - "odoo.exceptions.AccessError": OdooAccessError, - "odoo.exceptions.MissingError": OdooMissingRecordError, - "odoo.exceptions.ValidationError": OdooFieldError, - "odoo.exceptions.UserError": OdooUserError, - "odoo.exceptions.RedirectWarning": OdooUserError, - "odoo.exceptions.Warning": OdooUserError, +const ODOO_EXCEPTION_CODES = { + "odoo.exceptions.AccessDenied": "ODOO_AUTH_FAILED", + "odoo.exceptions.AccessError": "ODOO_ACCESS_DENIED", + "odoo.exceptions.MissingError": "ODOO_MISSING_RECORD", + "odoo.exceptions.ValidationError": "ODOO_FIELD_INVALID", + "odoo.exceptions.UserError": "ODOO_USER_ERROR", + "odoo.exceptions.RedirectWarning": "ODOO_USER_ERROR", }; -/** - * Build the right error subclass from an Odoo JSON-RPC error envelope. - * - * @param {{ message?: string, data?: { name?: string, message?: string, debug?: string, arguments?: unknown[] } }} envelope - * @param {object} extraDetails - */ -export function fromOdooError(envelope, extraDetails = {}) { - const data = envelope?.data || {}; - const message = data.message || envelope?.message || "Unknown Odoo error"; - const odooName = data.name; - const Cls = ODOO_EXCEPTION_MAP[odooName] ?? OdooServerError; - return new Cls(message, { - ...extraDetails, - odooErrorName: odooName, - odooDebug: data.debug, - odooArguments: data.arguments, - }); -} - -/** - * Format any thrown value into the MCP tool error response shape. - * Output: `[CODE] message` so Claude can pattern-match the code. - * - * @param {unknown} err - * @returns {{ isError: true, content: Array<{type: "text", text: string}> }} - */ -export function formatErrorForMcp(err) { - let code = "ODOO_INTERNAL"; - let message; - if (err instanceof McpOdooError) { - code = err.code; - message = err.message; - } else if (err instanceof Error) { - message = err.message; - } else { - message = String(err); - } - return { - isError: true, - content: [{ type: "text", text: `[${code}] ${message}` }], - }; +/** Map an Odoo JSON-RPC error envelope (`json.error`) to an OdooError. */ +export function fromOdooError(envelope) { + const data = envelope?.data ?? {}; + return new OdooError( + ODOO_EXCEPTION_CODES[data.name] ?? "ODOO_SERVER_ERROR", + data.message || envelope?.message || "Unknown Odoo error", + ); } diff --git a/odoo/lib/instructions.js b/odoo/lib/instructions.js index bd847d2..7324082 100644 --- a/odoo/lib/instructions.js +++ b/odoo/lib/instructions.js @@ -1,135 +1,30 @@ /** - * Cheatsheet shipped via `serverInfo.instructions` on initialize. Loaded once - * per session — keep it tight but cover the Odoo-specific gotchas Claude needs - * to compose CRUD calls correctly. + * Sent once per session via `serverInfo.instructions`. Hosts truncate this at + * about 2 KB (Claude Code does), so keep it under that — tool-specific detail + * belongs in the tool descriptions, not here. */ -export const INSTRUCTIONS = `You are connected to one or more Odoo v18+ instances via JSON-RPC. Every tool requires a "connection" name as its first argument. - -================================================================================ -TOOL CATALOGUE (pick the right one) -================================================================================ - list_connections Discover instances. Call once per session. - fields_get Inspect a model's schema (cached). Call BEFORE create/write. - - -- READ -- - search_read Fetch records with a domain (the workhorse). - search_count Just count matching records. Cheaper than search_read. - name_search Fuzzy lookup by display name → [[id,"name"], ...]. - read_group GROUP BY + aggregates. For reports / dashboards / KPIs. - - -- WRITE -- - create Insert one or many records. - write Update records by id list. - unlink Delete records by id list. - - -- ESCAPE HATCH -- - call_method Generic execute_kw — business actions, wizards, copy, - default_get, custom RPCs, anything else. - -================================================================================ -RECOMMENDED WORKFLOWS -================================================================================ - - Read / report: - fields_get → know what to ask for - search_read with explicit \`fields\` and \`limit\` - - Create: - fields_get → required fields, types, selection options - [call_method default_get] → computed defaults (optional but cheap) - create - - Update: - search_read → get the ids - [fields_get] → confirm field is writable (not readonly) - write - - Business action (confirm order, post invoice, validate picking, ...): - search_read → get the id - call_method → action_confirm / action_post / button_validate / ... - - Find a record by name (autocomplete-style): - name_search → [[id, "display_name"], ...] - - Count without fetching rows: - search_count(domain) → integer - search_count(domain, limit=1) → "is there at least one?" check - - Report / dashboard / KPI: - read_group with aggregates=["amount_total:sum","id:count"] - and groupby=["user_id","date_order:month"] - and lazy=false (flat multi-level groupby) - → each row carries __domain for drill-down via search_read. - -================================================================================ -DOMAIN SYNTAX (Polish prefix; default operator between leaves is AND) -================================================================================ - [["state","=","done"]] single leaf - [["state","=","done"], ["amount_total",">",100]] implicit AND - ["&", ["state","=","done"], ["amount_total",">",100]] explicit AND - ["|", ["partner_id","=",5], ["partner_id","=",7]] OR - ["!", ["active","=",true]] NOT - - Operators: = != > >= < <= =like like ilike =ilike in "not in" child_of parent_of =? - -================================================================================ -FIELD TYPES & WRITE FORMATS -================================================================================ - Many2one write integer id; reads return [id, "display_name"]. - One2many / M2m use command tuples: - [0, 0, {values}] create a new linked record - [1, id, {values}] update an existing linked record - [2, id, 0] delete the linked record - [3, id, 0] unlink (drop the relation only) - [4, id, 0] add an existing record - [5, 0, 0] remove all links - [6, 0, [ids]] replace the relation with this exact set - Date "YYYY-MM-DD" - Datetime "YYYY-MM-DD HH:MM:SS" UTC — Odoo stores datetimes in UTC. - Monetary write a currency_id sibling on the same record. - Binary base64-encoded string. - Selection use the technical key, not the display label. - -================================================================================ -FREQUENTLY USED MODELS -================================================================================ - res.partner, res.users, res.company, res.currency - product.template, product.product, product.category - sale.order, sale.order.line, purchase.order, purchase.order.line - account.move, account.move.line, account.journal - stock.picking, stock.move, stock.quant - crm.lead, hr.employee, project.project, project.task - -================================================================================ -COMMON BUSINESS METHODS (use via call_method) -================================================================================ - sale.order action_confirm, action_cancel, action_draft, action_quotation_sent - purchase.order button_confirm, button_cancel, button_draft - account.move action_post, button_draft, button_cancel - stock.picking action_confirm, action_assign, button_validate - crm.lead action_set_won, action_set_lost - -================================================================================ -ERROR CODES YOU WILL SEE (in the "[CODE] message" prefix) -================================================================================ - ODOO_INPUT_INVALID Your tool arguments failed schema validation. Fix the call. - ODOO_UNKNOWN_CONNECTION Unknown \`connection\`. Run list_connections again. - ODOO_AUTH_FAILED Bad API key / password, or 2FA on a password user. Stop, ask the user. - ODOO_ACCESS_DENIED The user lacks permission for this model/operation. Do not retry. - ODOO_MISSING_RECORD An id was deleted between your search and your call. Re-search. - ODOO_FIELD_INVALID A field constraint failed — bad value, missing required field, wrong type. - Read the message; usually fixable by adjusting the payload. - ODOO_USER_ERROR A business rule blocks the action (e.g. "cannot delete posted invoice"). - The message tells you which state transition you need first. - ODOO_SERVER_ERROR Unknown Odoo exception. Treat as fatal for this call. - ODOO_TRANSPORT_FAILED Network/HTTP/timeout. Safe to retry once; if it persists, report it. - -================================================================================ -EFFICIENCY -================================================================================ - • Always pass an explicit \`fields\` list to search_read — omitting it returns - every column including heavy binary/HTML fields and wastes tokens. - • fields_get is cached per (connection, model, fields, attributes) — repeat calls are free. - • Prefer search_read over search + read (one round-trip instead of two). - • For pagination use limit + offset, or use an \`order\` clause + filter on id. -`; +export const INSTRUCTIONS = `Connected to Odoo v18+ over JSON-RPC. Every tool except list_connections needs a "connection" name from list_connections. + +Workflow: +- fields_get before create/write (required fields, types, selection keys). +- search_read / name_search to get ids; never invent ids. +- call_method for business actions (action_confirm, action_post, button_validate, ...). +- read_group for totals and reports instead of summing rows yourself. + +Write formats: +- Many2one: integer id (reads return [id, "display_name"]). +- One2many / Many2many: list of command tuples + [0,0,{vals}] create · [1,id,{vals}] update · [2,id,0] delete · [3,id,0] unlink + [4,id,0] link existing · [5,0,0] clear · [6,0,[ids]] replace with exactly these +- Date "YYYY-MM-DD"; Datetime "YYYY-MM-DD HH:MM:SS" in UTC; Selection = technical key; Binary = base64. + +Errors start with "[CODE]": +- ODOO_UNKNOWN_CONNECTION run list_connections. +- ODOO_AUTH_FAILED bad API key/password (2FA users need an API key). Stop and ask the user. +- ODOO_ACCESS_DENIED missing permission. Do not retry. +- ODOO_MISSING_RECORD the id no longer exists. Search again. +- ODOO_FIELD_INVALID bad value or missing required field. Fix the payload. +- ODOO_USER_ERROR a business rule blocked it; the message says what must happen first. +- ODOO_SERVER_ERROR other Odoo exception. +- ODOO_TRANSPORT_FAILED network/HTTP/timeout. Retry once, then report. +"Input validation error" means the tool arguments are wrong — fix the call.`; diff --git a/odoo/lib/toolHandlers.js b/odoo/lib/toolHandlers.js index 370629f..2ac9404 100644 --- a/odoo/lib/toolHandlers.js +++ b/odoo/lib/toolHandlers.js @@ -1,629 +1,294 @@ -import { - ListConnectionsInputSchema, - FieldsGetInputSchema, - SearchReadInputSchema, - SearchCountInputSchema, - NameSearchInputSchema, - ReadGroupInputSchema, - CreateInputSchema, - WriteInputSchema, - UnlinkInputSchema, - CallMethodInputSchema, - parseOrThrow, -} from "./validators.js"; -import { formatErrorForMcp } from "./errors.js"; +import { z } from "zod"; +import { OdooError } from "./errors.js"; function ok(payload) { - return { - content: [{ type: "text", text: JSON.stringify(payload) }], - }; + return { content: [{ type: "text", text: JSON.stringify(payload) }] }; } -export class ToolHandlers { - /** @param {import("./clientRegistry.js").ClientRegistry} registry */ - constructor(registry) { - this.registry = registry; - } +const SEARCH_READ_DEFAULT_LIMIT = 100; - toolDescriptors() { - return [ - { - name: "list_connections", - description: [ - "WHEN: Call once at the start of a session to discover which Odoo instances are reachable.", - "WHAT: Returns the configured connections with their URL, database, user, and auth type.", - " Secrets (API key / password) are never returned.", - "", - "EXAMPLE INPUT: {}", - "EXAMPLE OUTPUT: {", - ' "connections": [', - ' { "name": "prod", "url": "https://erp.example.com", "db": "production",', - ' "username": "admin", "authType": "apikey", "authenticated": false, "timeoutMs": 60000 },', - ' { "name": "staging", "url": "https://staging.example.com", "db": "staging",', - ' "username": "admin", "authType": "password","authenticated": false, "timeoutMs": 60000 }', - " ]", - "}", - "", - "Use the `name` value as the `connection` argument on every other tool.", - ].join("\n"), - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - { - name: "fields_get", - description: [ - "WHEN: Before any create/write, or whenever you need to know what fields a model has.", - "WHY: Tells you which fields are required, each field's type (char, integer, date,", - " monetary, selection, many2one, ...), selection options, relational targets,", - " and read-only/computed flags. Lets you build correct payloads on the first try.", - "", - "PERFORMANCE: Results are cached per (model, fields, attributes) for the server lifetime —", - " calling this repeatedly for the same model is free.", - "", - "PARAMETERS:", - " • fields (optional) Restrict which field names to return. Use this if you only", - " care about a handful of fields — huge models (account.move, sale.order)", - " otherwise return ~200 fields and bloat the response.", - " • attributes (optional) Restrict which metadata keys come back per field.", - " Recommended trim: ['string','type','required','readonly','relation','selection','help']", - "", - "EXAMPLE INPUT (trimmed):", - ' { "connection": "prod", "model": "sale.order",', - ' "fields": ["name","state","partner_id","amount_total","date_order"],', - ' "attributes": ["type","required","relation","selection"] }', - "", - "EXAMPLE OUTPUT:", - ' { "model": "sale.order", "fields": {', - ' "name": { "type": "char", "required": true },', - ' "state": { "type": "selection","selection": [["draft","Quotation"],["sale","Sales Order"],...] },', - ' "partner_id": { "type": "many2one", "relation": "res.partner", "required": true },', - ' "amount_total": { "type": "monetary" },', - ' "date_order": { "type": "datetime" }', - " } }", - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string", description: "Connection name from list_connections." }, - model: { - type: "string", - description: "Odoo model technical name (e.g. 'sale.order').", - }, - fields: { - type: "array", - items: { type: "string" }, - minItems: 1, - description: - "Optional whitelist of field names to return. Strongly recommended for large models.", - }, - attributes: { - type: "array", - items: { type: "string" }, - minItems: 1, - description: "Optional whitelist of attribute keys to return per field.", - }, - }, - required: ["connection", "model"], - additionalProperties: false, - }, - }, - { - name: "search_read", - description: [ - "WHEN: For any query — listing, filtering, reporting. Always preferred over search+read.", - "WHAT: Combines search() and read() into a single round-trip.", - "", - "DOMAIN SYNTAX (Polish prefix; default operator between leaves is AND):", - ' [["state","=","sale"]] single leaf', - ' [["state","=","sale"], ["amount_total",">",100]] implicit AND', - ' ["&", ["state","=","sale"], ["amount_total",">",100]] explicit AND', - ' ["|", ["partner_id","=",5], ["partner_id","=",7]] OR', - ' ["!", ["active","=",true]] NOT', - " Operators: = != > >= < <= =like like ilike =ilike in 'not in' child_of parent_of =?", - "", - "ALWAYS pass `fields` — omitting it returns every column including heavy binary/HTML", - 'fields. Many2one values come back as [id, "display_name"]; selection as the technical key.', - "", - "EXAMPLE INPUT:", - ' { "connection": "prod", "model": "sale.order",', - ' "domain": [["state","=","sale"], ["date_order",">=","2026-01-01"]],', - ' "fields": ["name","partner_id","amount_total","state","date_order"],', - ' "limit": 20,', - ' "order": "date_order desc, id desc" }', - "", - "EXAMPLE OUTPUT:", - ' { "model": "sale.order", "count": 2, "records": [', - ' { "id": 17, "name": "S00017", "partner_id": [42,"Acme"], "amount_total": 1250.0,', - ' "state": "sale", "date_order": "2026-02-08 09:14:00" },', - ' { "id": 16, "name": "S00016", ... } ] }', - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string", description: "e.g. 'res.partner', 'sale.order'." }, - domain: { - type: "array", - description: "Odoo domain (Polish prefix). Use [] for no filter.", - }, - fields: { - type: "array", - items: { type: "string" }, - description: "Field names to read. Strongly recommended — omitting is expensive.", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 10000, - description: "Page size. Default unlimited — set this for safety.", - }, - offset: { - type: "integer", - minimum: 0, - description: "Pagination offset, combined with limit.", - }, - order: { - type: "string", - description: "SQL-style ORDER BY, e.g. 'date_order desc, id desc'.", - }, - }, - required: ["connection", "model"], - additionalProperties: false, - }, - }, - { - name: "search_count", - description: [ - "WHEN: You only need a count — 'how many partners in Vietnam', 'is the inbox empty', 'count overdue invoices'.", - "WHY: Far cheaper than search_read + counting client-side: no rows transferred.", - "", - "EXAMPLE INPUT:", - ' { "connection": "prod", "model": "res.partner",', - ' "domain": [["country_id.code","=","VN"]] }', - 'EXAMPLE OUTPUT: { "model": "res.partner", "count": 1247 }', - "", - "TIP: For 'is there at least one' checks, pass limit:1 — Odoo short-circuits at one match.", - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - domain: { - type: "array", - description: "Odoo domain (Polish prefix). Use [] for total count.", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 1000000, - description: "Optional ceiling; useful for 'has any?' (limit:1).", - }, - }, - required: ["connection", "model"], - additionalProperties: false, - }, - }, - { - name: "name_search", - description: [ - "WHEN: Looking up a record by partial name — autocomplete, 'find the Acme partner', 'which user is named Alice'.", - 'WHAT: Returns [[id, "display_name"], ...] sorted by relevance.', - "", - "DEFAULT OPERATOR: 'ilike' (case-insensitive substring). Use '=' for an exact match.", - "OPTIONAL DOMAIN: Restrict the search universe — e.g. only customers, only active records.", - "", - "EXAMPLE — fuzzy partner lookup, customers only:", - ' { "connection": "prod", "model": "res.partner",', - ' "name": "acme",', - ' "domain": [["customer_rank",">",0]],', - ' "limit": 5 }', - "EXAMPLE OUTPUT:", - ' { "model": "res.partner", "results": [[142,"Acme Co"], [156,"Acme Logistics"]] }', - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - name: { - type: "string", - description: - "Substring to match against the model's display name. Empty string returns the first N records.", - }, - domain: { - type: "array", - description: "Optional extra filter applied on top of the name match.", - }, - operator: { - type: "string", - enum: ["ilike", "like", "=ilike", "=like", "="], - description: - "Match operator. Default 'ilike' (case-insensitive substring); use '=' for exact.", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 1000, - description: "Max results, default 100.", - }, - }, - required: ["connection", "model"], - additionalProperties: false, - }, - }, - { - name: "read_group", - description: [ - "WHEN: For any aggregation, dashboard, report, or 'group X by Y and sum Z' question.", - "WHAT: Runs SQL GROUP BY on the server — way more efficient than search_read + client-side aggregation.", - "", - 'AGGREGATES (`aggregates` arg): array of "field:func" or "alias:func(field)" strings.', - " Functions: sum | avg | min | max | count | count_distinct | array_agg | bool_and | bool_or", - ' Examples: "amount_total:sum", "id:count", "days_open:avg", "closed_count:count(id)"', - "", - "GROUPBY (`groupby` arg): array of field names. Date fields support granularity suffix.", - " Granularities: hour | day | week | month | quarter | year", - " Integer date parts: year_number | quarter_number | month_number | iso_week_number", - " | day_of_year | day_of_month | day_of_week | hour_number | minute_number", - ' Example: "date_order:month" → groups every month.', - "", - "LAZY (`lazy` arg, default true — Odoo's default):", - " • lazy:true → groups only by the FIRST groupby field; the rest are stuffed into __context", - " (drill-down style; you call read_group again with the returned __domain).", - " • lazy:false → groups by ALL groupby fields at once (flat table, recommended for AI reports).", - "", - "EXAMPLE — monthly revenue by salesperson:", - ' { "connection": "prod", "model": "sale.order",', - ' "domain": [["state","in",["sale","done"]], ["date_order",">=","2026-01-01"]],', - ' "aggregates": ["amount_total:sum","id:count"],', - ' "groupby": ["user_id","date_order:month"],', - ' "lazy": false,', - ' "orderby": "user_id, date_order" }', - "", - "EXAMPLE OUTPUT (lazy:false):", - ' { "model": "sale.order", "groups": [', - ' { "user_id": [5,"Alice"], "date_order:month": "January 2026",', - ' "amount_total": 125000.0, "id": 42,', - ' "__domain": [["user_id","=",5],["date_order",">=","2026-01-01"],["date_order","<","2026-02-01"]] },', - ' { "user_id": [5,"Alice"], "date_order:month": "February 2026",', - ' "amount_total": 98000.0, "id": 31, ... },', - " ...] }", - "", - "Each group includes `__domain` you can pass directly to search_read for a drill-down list.", - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - domain: { - type: "array", - description: "Filter applied before grouping. [] to include everything.", - }, - aggregates: { - type: "array", - items: { type: "string" }, - minItems: 1, - description: - "Aggregate specs: 'field:func' or 'alias:func(field)'. At least one required.", - }, - groupby: { - type: "array", - items: { type: "string" }, - minItems: 1, - description: - "Group fields. Date fields accept ':granularity' suffix (e.g. 'date_order:month').", - }, - offset: { type: "integer", minimum: 0 }, - limit: { - type: "integer", - minimum: 1, - maximum: 10000, - description: "Max groups returned.", - }, - orderby: { - type: "string", - description: "ORDER BY clause for groups, e.g. 'amount_total desc'.", - }, - lazy: { - type: "boolean", - description: "Default true (Odoo native). Set false for flat multi-level groupby.", - }, - }, - required: ["connection", "model", "aggregates", "groupby"], - additionalProperties: false, - }, - }, - { - name: "create", - description: [ - "WHEN: To insert one or more new records.", - "PRECONDITION: Call fields_get(model) first to know required fields, types, and selection keys.", - "", - "PAYLOAD SHAPES:", - " • Single dict → returns { id: number }", - " • Array of dicts (bulk) → returns { ids: number[] }", - "", - "RELATIONAL FIELDS:", - " • Many2one: write the integer id of the target.", - " • One2many / Many2many: use command tuples — see the server instructions.", - " Most common: [[0, 0, {values}], ...] to create inline children,", - " [[6, 0, [ids]]] to set a Many2many to an exact set.", - "", - "EXAMPLE — partner with an inline contact:", - ' { "connection": "prod", "model": "res.partner",', - ' "values": {', - ' "name": "Acme Co",', - ' "is_company": true,', - ' "email": "billing@acme.com",', - ' "child_ids": [[0, 0, { "name": "Jane Doe", "function": "CFO" }]]', - " } }", - 'EXAMPLE OUTPUT: { "model": "res.partner", "id": 142 }', - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - values: { - description: "Record values, or array of records for bulk create.", - oneOf: [ - { type: "object" }, - { type: "array", items: { type: "object" }, minItems: 1 }, - ], - }, - }, - required: ["connection", "model", "values"], - additionalProperties: false, - }, - }, - { - name: "write", - description: [ - "WHEN: To update existing records.", - "PRECONDITION: You already have ids (from a prior search_read). Never invent ids.", - "", - "All ids receive the same `values` patch. To set different values per record, call write multiple times.", - "Same value-format rules as create (Many2one = int, O2m/M2m = command tuples).", - "", - "EXAMPLE — archive two partners:", - ' { "connection": "prod", "model": "res.partner", "ids": [142,143], "values": { "active": false } }', - 'EXAMPLE OUTPUT: { "model": "res.partner", "ids": [142,143], "success": true }', - "", - "If the update is blocked by a business rule you will see ODOO_USER_ERROR — read the message,", - "it usually explains the state transition you need to perform first via call_method.", - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - ids: { type: "array", items: { type: "integer", minimum: 1 }, minItems: 1 }, - values: { type: "object" }, - }, - required: ["connection", "model", "ids", "values"], - additionalProperties: false, - }, - }, - { - name: "unlink", - description: [ - "WHEN: To permanently delete records. Use sparingly — most Odoo records should be archived", - "(write active=false) rather than deleted, since posted/confirmed documents block unlink.", - "", - 'EXAMPLE: { "connection": "prod", "model": "res.partner", "ids": [142] }', - 'EXAMPLE OUTPUT: { "model": "res.partner", "ids": [142], "success": true }', - "", - "Common reasons unlink fails:", - " • ODOO_USER_ERROR — record is posted/confirmed; cancel or reverse it instead.", - " • ODOO_ACCESS_DENIED — the user lacks delete permission on this model.", - " • ODOO_MISSING_RECORD — the id was already deleted; safe to ignore.", - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - ids: { type: "array", items: { type: "integer", minimum: 1 }, minItems: 1 }, - }, - required: ["connection", "model", "ids"], - additionalProperties: false, - }, - }, - { - name: "call_method", - description: [ - "WHEN: For anything that isn't plain CRUD — business actions, wizards, fuzzy lookups,", - " computed defaults, or any custom RPC method exposed by a model.", - "WHAT: Generic wrapper over Odoo's execute_kw — calls `model.method(*args, **kwargs)`.", - "", - "RECIPES (the most useful ones):", - "", - " Find by name (fuzzy):", - " model='res.partner', method='name_search',", - " kwargs={ name: 'Acme', limit: 5 }", - " → returns [[id, 'display_name'], ...]", - "", - " Confirm a sale order:", - " model='sale.order', method='action_confirm', args=[[order_id]]", - "", - " Post a customer invoice:", - " model='account.move', method='action_post', args=[[move_id]]", - "", - " Validate a stock picking:", - " model='stock.picking', method='button_validate', args=[[picking_id]]", - "", - " Get computed defaults before create:", - " model='sale.order', method='default_get', args=[['partner_id','currency_id','company_id']]", - "", - " Duplicate a record:", - " model='product.template', method='copy', args=[[template_id]], kwargs={ default: { name: 'Copy of X' } }", - "", - " Read with id list when you already have ids and just need fields:", - " model='res.partner', method='read', args=[[1,2,3]], kwargs={ fields: ['name','email'] }", - "", - "RETURNS: whatever the method returns — bool, dict, id list, action descriptor.", - "The result is passed through verbatim under the `result` key.", - ].join("\n"), - inputSchema: { - type: "object", - properties: { - connection: { type: "string" }, - model: { type: "string" }, - method: { - type: "string", - description: "Method name, e.g. 'action_confirm', 'name_search'.", - }, - args: { - type: "array", - description: - "Positional args. For action methods this is [[ids]] (note the double-wrap).", - }, - kwargs: { - type: "object", - description: - "Keyword args. Frequently used: { context: { lang: 'en_US' } }, { limit: 5 }.", - }, - }, - required: ["connection", "model", "method"], - additionalProperties: false, - }, - }, - ]; - } +// ponytail: no regex/length checks on names — Odoo already rejects unknown connections, +// models and (private) methods with a clear error, and each check costs schema tokens on 9 tools. +const Connection = z.string().describe("Connection name from list_connections."); +const Model = z.string().describe("Technical model name, e.g. 'sale.order'."); +// Leaf [field, operator, value] or one of the prefix operators. +const Domain = z + .array(z.union([z.enum(["&", "|", "!"]), z.tuple([z.string(), z.string(), z.unknown()])])) + .describe("Odoo domain (Polish prefix). Omit or [] for no filter."); +const Ids = z.array(z.number().int().positive()).min(1); +const Values = z.record(z.unknown()); +const Names = z.array(z.string().min(1)).min(1); - async dispatch(toolName, args) { - try { - switch (toolName) { - case "list_connections": - return await this._listConnections(args); - case "fields_get": - return await this._fieldsGet(args); - case "search_read": - return await this._searchRead(args); - case "search_count": - return await this._searchCount(args); - case "name_search": - return await this._nameSearch(args); - case "read_group": - return await this._readGroup(args); - case "create": - return await this._create(args); - case "write": - return await this._write(args); - case "unlink": - return await this._unlink(args); - case "call_method": - return await this._callMethod(args); - default: - return formatErrorForMcp(new Error(`Unknown tool: ${toolName}`)); - } - } catch (err) { - return formatErrorForMcp(err); - } - } - - async _listConnections(args) { - await parseOrThrow(ListConnectionsInputSchema, args, "list_connections"); - return ok({ connections: this.registry.list() }); - } +const READ_ONLY = { readOnlyHint: true }; - async _fieldsGet(args) { - const { connection, model, fields, attributes } = await parseOrThrow( - FieldsGetInputSchema, - args, - "fields_get", +/** + * Register every tool on an McpServer. Inputs are validated by the SDK against + * the zod schemas; handlers throw OdooError, whose message already carries the + * `[CODE]` prefix the instructions document. + * + * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server + * @param {Map} clients + */ +export function registerTools(server, clients) { + function client(name) { + const c = clients.get(name); + if (c) return c; + const available = [...clients.keys()]; + throw new OdooError( + "ODOO_UNKNOWN_CONNECTION", + available.length === 0 + ? "No Odoo connections configured. Set ODOO__URL/DB/USERNAME and either ODOO__API_KEY or ODOO__PASSWORD." + : `Unknown connection "${name}". Available: ${available.join(", ")}`, ); - const client = this.registry.get(connection); - const result = await client.fieldsGet(model, { fields, attributes }); - return ok({ model, fields: result }); } - async _searchRead(args) { - const input = await parseOrThrow(SearchReadInputSchema, args, "search_read"); - const client = this.registry.get(input.connection); - const records = await client.searchRead(input.model, { - domain: input.domain ?? [], - fields: input.fields, - limit: input.limit, - offset: input.offset, - order: input.order, - }); - return ok({ model: input.model, count: records.length, records }); - } + server.registerTool( + "list_connections", + { + description: + "List the configured Odoo instances (name, url, db, username, authType — never secrets). " + + "Use `name` as the `connection` argument of every other tool.", + annotations: READ_ONLY, + }, + async () => ok({ connections: [...clients.values()].map((c) => c.describe()) }), + ); - async _searchCount(args) { - const { connection, model, domain, limit } = await parseOrThrow( - SearchCountInputSchema, - args, - "search_count", - ); - const client = this.registry.get(connection); - const count = await client.searchCount(model, domain ?? [], limit); - return ok({ model, count }); - } + server.registerTool( + "fields_get", + { + description: [ + "Schema of a model: per-field type, required, readonly, relation, selection. Call before create/write.", + "Cached for the server lifetime, so repeats are free. Big models (account.move, sale.order) have ~200", + "fields — pass `fields` and `attributes` to trim.", + 'EXAMPLE: { "connection": "prod", "model": "sale.order", "fields": ["state","partner_id"],', + ' "attributes": ["type","required","relation","selection"] }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + fields: Names.optional().describe("Only these fields."), + attributes: Names.optional().describe("Only these metadata keys per field."), + }) + .strict(), + annotations: READ_ONLY, + }, + async ({ connection, model, fields, attributes }) => + ok({ model, fields: await client(connection).fieldsGet(model, { fields, attributes }) }), + ); - async _nameSearch(args) { - const { connection, model, name, domain, operator, limit } = await parseOrThrow( - NameSearchInputSchema, - args, - "name_search", - ); - const client = this.registry.get(connection); - const results = await client.nameSearch(model, { name, domain, operator, limit }); - return ok({ model, results }); - } + server.registerTool( + "search_read", + { + description: [ + "Read records matching a domain in one round-trip. Always pass `fields` — omitting it returns every", + "column, including heavy binary/HTML fields.", + "Domain is Polish prefix; leaves are AND-ed by default:", + ' [["state","=","sale"], ["amount_total",">",100]]', + ' ["|", ["partner_id","=",5], ["partner_id","=",7]] ("!" negates the next leaf)', + " Operators: = != > >= < <= like ilike =like =ilike in 'not in' child_of parent_of;", + ' dotted paths work: ["partner_id.country_id.code","=","VN"].', + 'Many2one values come back as [id, "display_name"]. Default limit 100; `limit_reached: true` means', + "there may be more — page with `offset`.", + 'EXAMPLE: { "connection": "prod", "model": "sale.order", "domain": [["state","=","sale"]],', + ' "fields": ["name","partner_id","amount_total"], "limit": 20, "order": "date_order desc" }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + domain: Domain.optional(), + fields: z.array(z.string()).optional().describe("Field names to read."), + limit: z.number().int().min(1).max(10_000).default(SEARCH_READ_DEFAULT_LIMIT), + offset: z.number().int().min(0).optional(), + order: z.string().max(256).optional().describe("e.g. 'date_order desc, id desc'."), + }) + .strict(), + annotations: READ_ONLY, + }, + async ({ connection, model, domain, fields, limit, offset, order }) => { + const records = await client(connection).searchRead(model, { + domain, + fields, + limit, + offset, + order, + }); + const more = records.length === limit ? { limit_reached: true } : {}; + return ok({ model, count: records.length, ...more, records }); + }, + ); - async _readGroup(args) { - const input = await parseOrThrow(ReadGroupInputSchema, args, "read_group"); - const client = this.registry.get(input.connection); - const groups = await client.readGroup(input.model, { - domain: input.domain ?? [], - aggregates: input.aggregates, - groupby: input.groupby, - offset: input.offset, - limit: input.limit, - orderby: input.orderby, - lazy: input.lazy, - }); - return ok({ model: input.model, count: groups.length, groups }); - } + server.registerTool( + "search_count", + { + description: [ + 'Count records matching a domain without fetching them. Pass limit: 1 for an "is there any?" check.', + 'EXAMPLE: { "connection": "prod", "model": "res.partner", "domain": [["country_id.code","=","VN"]] }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + domain: Domain.optional(), + limit: z.number().int().min(1).max(1_000_000).optional(), + }) + .strict(), + annotations: READ_ONLY, + }, + async ({ connection, model, domain, limit }) => + ok({ model, count: await client(connection).searchCount(model, domain, limit) }), + ); - async _create(args) { - const { connection, model, values } = await parseOrThrow(CreateInputSchema, args, "create"); - const client = this.registry.get(connection); - const result = await client.create(model, values); - return ok(Array.isArray(values) ? { model, ids: result } : { model, id: result }); - } + server.registerTool( + "name_search", + { + description: [ + 'Find records by display name → { results: [[id, "display_name"], ...] }. Operator defaults to', + 'ilike (substring); use "=" for an exact match. `domain` narrows the search.', + 'EXAMPLE: { "connection": "prod", "model": "res.partner", "name": "acme",', + ' "domain": [["customer_rank",">",0]], "limit": 5 }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + name: z.string().max(256).optional(), + domain: Domain.optional(), + operator: z.enum(["ilike", "like", "=ilike", "=like", "="]).optional(), + limit: z.number().int().min(1).max(1_000).optional().describe("Default 100."), + }) + .strict(), + annotations: READ_ONLY, + }, + async ({ connection, model, name, domain, operator, limit }) => + ok({ + model, + results: await client(connection).nameSearch(model, { name, domain, operator, limit }), + }), + ); - async _write(args) { - const { connection, model, ids, values } = await parseOrThrow(WriteInputSchema, args, "write"); - const client = this.registry.get(connection); - const result = await client.write(model, ids, values); - return ok({ model, ids, success: result }); - } + server.registerTool( + "read_group", + { + description: [ + "GROUP BY with aggregates, computed by the database — use it for totals, reports and KPIs instead", + "of summing search_read rows.", + 'aggregates: "field:func" or "alias:func(field)"; func = sum avg min max count count_distinct', + " array_agg bool_and bool_or.", + "groupby: field names; dates take a suffix: :day :week :month :quarter :year (also :hour and", + " integer parts such as :month_number, :day_of_week).", + "lazy (default true) groups by the first groupby only; pass false for a flat table across all of them.", + "Each group carries `__domain` — pass it to search_read to list that group's records.", + 'EXAMPLE: { "connection": "prod", "model": "sale.order", "domain": [["state","=","sale"]],', + ' "aggregates": ["amount_total:sum"], "groupby": ["user_id","date_order:month"], "lazy": false }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + domain: Domain.optional(), + aggregates: z.array(z.string().min(1).max(128)).min(1), + groupby: z.array(z.string().min(1).max(128)).min(1), + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(10_000).optional(), + orderby: z.string().max(256).optional().describe("e.g. 'amount_total desc'."), + lazy: z.boolean().optional(), + }) + .strict(), + annotations: READ_ONLY, + }, + async ({ connection, model, ...opts }) => { + const groups = await client(connection).readGroup(model, opts); + return ok({ model, count: groups.length, groups }); + }, + ); - async _unlink(args) { - const { connection, model, ids } = await parseOrThrow(UnlinkInputSchema, args, "unlink"); - const client = this.registry.get(connection); - const result = await client.unlink(model, ids); - return ok({ model, ids, success: result }); - } + server.registerTool( + "create", + { + description: [ + "Create one record (values = object → { id }) or many (values = array → { ids }).", + "Call fields_get first for required fields and selection keys. Many2one = integer id;", + "One2many/Many2many = command tuples (see server instructions), e.g. [[0,0,{...}]] adds children.", + 'EXAMPLE: { "connection": "prod", "model": "res.partner",', + ' "values": { "name": "Acme Co", "is_company": true, "child_ids": [[0,0,{ "name": "Jane" }]] } }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + values: z.union([Values, z.array(Values).min(1)]), + }) + .strict(), + annotations: { destructiveHint: false }, + }, + async ({ connection, model, values }) => { + const result = await client(connection).create(model, values); + return ok(Array.isArray(values) ? { model, ids: result } : { model, id: result }); + }, + ); - async _callMethod(args) { - const { - connection, - model, - method, - args: methodArgs, - kwargs, - } = await parseOrThrow(CallMethodInputSchema, args, "call_method"); - const client = this.registry.get(connection); - const result = await client.callKw(model, method, methodArgs ?? [], kwargs ?? {}); - return ok({ model, method, result }); - } + server.registerTool( + "write", + { + description: [ + "Apply the same `values` to every id (ids come from a prior search — never invent them).", + "Same value formats as create. ODOO_USER_ERROR usually means a state transition is needed first", + "(via call_method).", + 'EXAMPLE (archive): { "connection": "prod", "model": "res.partner", "ids": [142,143],', + ' "values": { "active": false } }', + ].join("\n"), + inputSchema: z + .object({ connection: Connection, model: Model, ids: Ids, values: Values }) + .strict(), + annotations: { destructiveHint: true, idempotentHint: true }, + }, + async ({ connection, model, ids, values }) => + ok({ model, ids, success: await client(connection).write(model, ids, values) }), + ); + + server.registerTool( + "unlink", + { + description: [ + "Permanently delete records. Prefer archiving (write active=false): posted/confirmed documents", + "usually refuse unlink with ODOO_USER_ERROR.", + 'EXAMPLE: { "connection": "prod", "model": "res.partner", "ids": [142] }', + ].join("\n"), + inputSchema: z.object({ connection: Connection, model: Model, ids: Ids }).strict(), + annotations: { destructiveHint: true, idempotentHint: true }, + }, + async ({ connection, model, ids }) => + ok({ model, ids, success: await client(connection).unlink(model, ids) }), + ); + + server.registerTool( + "call_method", + { + description: [ + "Call any public model method (model.method(*args, **kwargs) via execute_kw): business actions,", + "wizards, anything that is not plain CRUD. The return value comes back as `result`.", + "Record methods take the ids wrapped as the first positional arg: args = [[id]].", + " sale.order action_confirm / action_cancel account.move action_post / button_draft", + " purchase.order button_confirm stock.picking button_validate", + ' default_get args=[["partner_id","company_id"]] defaults before create', + ' copy args=[[id]], kwargs={ "default": { "name": "Copy" } }', + ' read args=[[1,2]], kwargs={ "fields": ["name"] }', + 'EXAMPLE: { "connection": "prod", "model": "sale.order", "method": "action_confirm", "args": [[17]] }', + ].join("\n"), + inputSchema: z + .object({ + connection: Connection, + model: Model, + method: z.string().describe("e.g. 'action_confirm'."), + args: z.array(z.unknown()).optional(), + kwargs: Values.optional().describe("e.g. { context: { lang: 'vi_VN' } }"), + }) + .strict(), + annotations: { destructiveHint: true }, + }, + async ({ connection, model, method, args, kwargs }) => + ok({ model, method, result: await client(connection).callKw(model, method, args, kwargs) }), + ); } diff --git a/odoo/lib/validators.js b/odoo/lib/validators.js deleted file mode 100644 index 2ab2848..0000000 --- a/odoo/lib/validators.js +++ /dev/null @@ -1,153 +0,0 @@ -import { z } from "zod"; - -const ConnectionName = z - .string() - .min(1) - .max(64) - .regex(/^[a-z][a-z0-9_]*$/, { - message: "connection name must be lowercase, start with a letter, and contain only [a-z0-9_]", - }); - -const ModelName = z - .string() - .min(1) - .max(128) - .regex(/^[a-z][a-z0-9_.]*$/, { - message: "Odoo model name must match [a-z][a-z0-9_.]* (e.g., 'sale.order', 'res.partner')", - }); - -const MethodName = z - .string() - .min(1) - .max(128) - .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, { - message: "method name must match [A-Za-z_][A-Za-z0-9_]*", - }); - -// Odoo domain leaf: [field, operator, value] or "&" / "|" / "!" -const DomainItem = z.union([ - z.enum(["&", "|", "!"]), - z.tuple([z.string(), z.string(), z.unknown()]), -]); -const Domain = z.array(DomainItem); - -const IdArray = z.array(z.number().int().positive()).min(1); - -const PositiveInt = z.number().int().positive(); -const NonNegativeInt = z.number().int().min(0); - -const FieldNameArray = z.array(z.string().min(1)).min(1); - -export const ListConnectionsInputSchema = z.object({}).strict(); - -export const FieldsGetInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - fields: FieldNameArray.optional(), - attributes: FieldNameArray.optional(), - }) - .strict(); - -export const SearchReadInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - domain: Domain.optional(), - fields: z.array(z.string()).optional(), - limit: PositiveInt.max(10_000).optional(), - offset: NonNegativeInt.optional(), - order: z.string().max(256).optional(), - }) - .strict(); - -export const CreateInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - values: z.union([z.record(z.unknown()), z.array(z.record(z.unknown())).min(1)]), - }) - .strict(); - -export const WriteInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - ids: IdArray, - values: z.record(z.unknown()), - }) - .strict(); - -export const UnlinkInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - ids: IdArray, - }) - .strict(); - -export const CallMethodInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - method: MethodName, - args: z.array(z.unknown()).optional(), - kwargs: z.record(z.unknown()).optional(), - }) - .strict(); - -export const SearchCountInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - domain: Domain.optional(), - limit: PositiveInt.max(1_000_000).optional(), - }) - .strict(); - -export const NameSearchInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - name: z.string().max(256).optional(), - domain: Domain.optional(), - operator: z.enum(["ilike", "like", "=ilike", "=like", "="]).optional(), - limit: PositiveInt.max(1_000).optional(), - }) - .strict(); - -// Aggregate spec like "amount_total:sum" / "name:count" / "alias:agg(field)". -// We don't try to validate the body strictly — Odoo's regex covers a wide -// surface area. We just require non-empty strings. -const AggregateSpec = z.string().min(1).max(128); - -export const ReadGroupInputSchema = z - .object({ - connection: ConnectionName, - model: ModelName, - domain: Domain.optional(), - aggregates: z.array(AggregateSpec).min(1), - groupby: z.array(z.string().min(1).max(128)).min(1), - offset: NonNegativeInt.optional(), - limit: PositiveInt.max(10_000).optional(), - orderby: z.string().max(256).optional(), - lazy: z.boolean().optional(), - }) - .strict(); - -/** - * Parse input through a zod schema. On failure, throws InputValidationError - * with a human-readable message. Lazy-imports errors to avoid a circular dep. - */ -export async function parseOrThrow(schema, input, toolName) { - const result = schema.safeParse(input); - if (result.success) return result.data; - const { InputValidationError } = await import("./errors.js"); - const issues = result.error.issues - .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) - .join("; "); - throw new InputValidationError(`Invalid input for ${toolName}: ${issues}`, { - toolName, - issues: result.error.issues, - }); -} diff --git a/odoo/mcpServer.js b/odoo/mcpServer.js index 7b75227..ec5484e 100644 --- a/odoo/mcpServer.js +++ b/odoo/mcpServer.js @@ -1,96 +1,55 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createRequire } from "node:module"; -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { parseEnv } from "./lib/config.js"; -import { ClientRegistry } from "./lib/clientRegistry.js"; -import { ToolHandlers } from "./lib/toolHandlers.js"; +import { OdooClient } from "./lib/client.js"; +import { registerTools } from "./lib/toolHandlers.js"; import { INSTRUCTIONS } from "./lib/instructions.js"; const require = createRequire(import.meta.url); const { name: SERVER_NAME, version: SERVER_VERSION } = require("./package.json"); const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; -function makeLogger() { - const levelName = (process.env.MCP_ODOO_LOG_LEVEL ?? "info").toLowerCase(); - const min = LOG_LEVELS[levelName] ?? LOG_LEVELS.info; - function log(level, fields) { - if (LOG_LEVELS[level] < min) return; - const ts = new Date().toISOString(); - const flat = Object.entries(fields) - .map(([k, v]) => `${k}=${typeof v === "string" ? JSON.stringify(v) : v}`) - .join(" "); - console.error(`[${ts}] [${level}] ${flat}`); - } - return { - debug: (fields) => log("debug", fields), - info: (fields) => log("info", fields), - warn: (fields) => log("warn", fields), - error: (fields) => log("error", fields), - }; -} - export class OdooMCPServer { constructor({ env = process.env } = {}) { - this.logger = makeLogger(); + const min = LOG_LEVELS[(env.MCP_ODOO_LOG_LEVEL ?? "info").toLowerCase()] ?? LOG_LEVELS.info; + // stderr only: stdout is the MCP protocol stream. + this.log = (level, fields) => { + if (LOG_LEVELS[level] < min) return; + const flat = Object.entries(fields) + .map(([k, v]) => `${k}=${typeof v === "string" ? JSON.stringify(v) : v}`) + .join(" "); + console.error(`[${new Date().toISOString()}] [${level}] ${flat}`); + }; const { connections, errors } = parseEnv(env); for (const e of errors) { - const level = e.severity === "warn" ? "warn" : "error"; - this.logger[level]({ event: "config_issue", connection: e.name, message: e.message }); + this.log(e.severity, { event: "config_issue", connection: e.name, message: e.message }); } - const names = Object.keys(connections); - if (names.length === 0) { - this.logger.warn({ + const clients = new Map(Object.values(connections).map((c) => [c.name, new OdooClient(c)])); + if (clients.size === 0) { + this.log("warn", { event: "no_connections", hint: "Set ODOO__URL, ODOO__DB, ODOO__USERNAME, and one of ODOO__API_KEY / ODOO__PASSWORD.", }); } else { - this.logger.info({ + this.log("info", { event: "loaded_connections", - count: names.length, - connections: names.join(", "), + connections: [...clients.keys()].join(", "), }); } - this.registry = new ClientRegistry(connections); - this.tools = new ToolHandlers(this.registry); - - this.server = new Server( + this.server = new McpServer( { name: SERVER_NAME, version: SERVER_VERSION }, - { - capabilities: { tools: {} }, - instructions: INSTRUCTIONS, - }, - ); - - this._registerHandlers(); - this._installShutdownHandlers(); - } - - _registerHandlers() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: this.tools.toolDescriptors(), - })); - this.server.setRequestHandler(CallToolRequestSchema, async (req) => - this.tools.dispatch(req.params.name, req.params.arguments ?? {}), + { instructions: INSTRUCTIONS }, ); - } - - _installShutdownHandlers() { - const shutdown = (sig) => { - this.logger.info({ event: "shutdown", signal: sig }); - process.exit(0); - }; - process.on("SIGINT", () => shutdown("SIGINT")); - process.on("SIGTERM", () => shutdown("SIGTERM")); + registerTools(this.server, clients); } async run() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - this.logger.info({ event: "ready" }); + await this.server.connect(new StdioServerTransport()); + this.log("info", { event: "ready" }); } } diff --git a/odoo/package.json b/odoo/package.json index 656f915..1136652 100644 --- a/odoo/package.json +++ b/odoo/package.json @@ -3,14 +3,12 @@ "version": "0.0.3", "description": "MCP server for Odoo v18+ via JSON-RPC. Multi-instance support, API key or password auth, generic CRUD tools that let Claude compose workflows on any Odoo model.", "main": "index.js", - "types": "index.d.ts", "type": "module", "bin": { "mcp-odoo": "index.js" }, "files": [ "index.js", - "index.d.ts", "mcpServer.js", "lib/", "README.md", @@ -51,7 +49,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.31.0", - "zod": "^3.23.8" + "zod": "^3.25.0" }, "devDependencies": { "@vitest/coverage-v8": "^3.2.6", diff --git a/odoo/test/unit/client.test.js b/odoo/test/unit/client.test.js index 5b4a00d..bb46606 100644 --- a/odoo/test/unit/client.test.js +++ b/odoo/test/unit/client.test.js @@ -84,144 +84,69 @@ describe("OdooClient", () => { expect(authCalls).toHaveLength(1); }); - it("throws AuthError when authenticate returns false", async () => { + it("throws ODOO_AUTH_FAILED when authenticate returns false", async () => { const { fn } = makeFetch([{ json: { result: false } }]); const client = mkClient({}, fn); await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "AuthError", code: "ODOO_AUTH_FAILED", }); }); - it("maps odoo.exceptions.UserError → OdooUserError", async () => { + it.each([ + ["odoo.exceptions.UserError", "ODOO_USER_ERROR"], + ["odoo.exceptions.RedirectWarning", "ODOO_USER_ERROR"], + ["odoo.exceptions.ValidationError", "ODOO_FIELD_INVALID"], + ["odoo.exceptions.MissingError", "ODOO_MISSING_RECORD"], + ["odoo.exceptions.AccessError", "ODOO_ACCESS_DENIED"], + ["odoo.exceptions.AccessDenied", "ODOO_AUTH_FAILED"], + ["some.weird.exception", "ODOO_SERVER_ERROR"], + ])("maps %s → %s with a [CODE]-prefixed message", async (name, code) => { const { fn } = makeFetch([ { json: { result: 1 } }, - { - json: { - error: { - code: 200, - message: "Odoo Server Error", - data: { name: "odoo.exceptions.UserError", message: "Cannot delete posted invoice" }, - }, - }, - }, + { json: { error: { message: "Odoo Server Error", data: { name, message: "boom" } } } }, ]); const client = mkClient({}, fn); await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "OdooUserError", - code: "ODOO_USER_ERROR", - message: "Cannot delete posted invoice", + name: "OdooError", + code, + message: `[${code}] boom`, }); }); - it("maps odoo.exceptions.ValidationError → OdooFieldError", async () => { - const { fn } = makeFetch([ - { json: { result: 1 } }, - { - json: { - error: { - data: { name: "odoo.exceptions.ValidationError", message: "Field 'name' is required" }, - }, - }, - }, - ]); - const client = mkClient({}, fn); - await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "OdooFieldError", - code: "ODOO_FIELD_INVALID", - }); - }); - - it("maps odoo.exceptions.MissingError → OdooMissingRecordError", async () => { - const { fn } = makeFetch([ - { json: { result: 1 } }, - { - json: { - error: { - data: { name: "odoo.exceptions.MissingError", message: "Record does not exist" }, - }, - }, - }, - ]); - const client = mkClient({}, fn); - await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "OdooMissingRecordError", - code: "ODOO_MISSING_RECORD", - }); - }); - - it("maps odoo.exceptions.AccessError → OdooAccessError", async () => { - const { fn } = makeFetch([ - { json: { result: 1 } }, - { - json: { - error: { - data: { name: "odoo.exceptions.AccessError", message: "Permission denied on model X" }, - }, - }, - }, - ]); + it("translates non-2xx HTTP responses into ODOO_TRANSPORT_FAILED", async () => { + const { fn } = makeFetch([{ ok: false, status: 502, statusText: "Bad Gateway", json: {} }]); const client = mkClient({}, fn); await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "OdooAccessError", - code: "ODOO_ACCESS_DENIED", - }); - }); - - it("maps odoo.exceptions.AccessDenied → AuthError (credential failure)", async () => { - const { fn } = makeFetch([ - { json: { result: 1 } }, - { - json: { - error: { - data: { name: "odoo.exceptions.AccessDenied", message: "Access Denied" }, - }, - }, - }, - ]); - const client = mkClient({}, fn); - await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "AuthError", - code: "ODOO_AUTH_FAILED", + code: "ODOO_TRANSPORT_FAILED", + message: expect.stringContaining("HTTP 502"), }); }); - it("falls back to OdooServerError on unknown exception names", async () => { - const { fn } = makeFetch([ - { json: { result: 1 } }, - { - json: { - error: { - data: { name: "some.weird.exception", message: "boom" }, - }, - }, - }, - ]); - const client = mkClient({}, fn); - await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "OdooServerError", - code: "ODOO_SERVER_ERROR", + it("translates fetch failures into ODOO_TRANSPORT_FAILED", async () => { + const fn = vi.fn(async () => { + throw new TypeError("network down"); }); - }); - - it("translates non-2xx HTTP responses into TransportError", async () => { - const { fn } = makeFetch([ - { ok: false, status: 502, statusText: "Bad Gateway", json: {} }, - ]); const client = mkClient({}, fn); await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "TransportError", code: "ODOO_TRANSPORT_FAILED", + message: expect.stringContaining("network down"), }); }); - it("translates fetch failures into TransportError", async () => { - const fn = vi.fn(async () => { - throw new TypeError("network down"); - }); - const client = mkClient({}, fn); + // Bug fixed: the old setTimeout was cleared once headers arrived, so a stalled body hung forever. + it("times out while reading a stalled response body", async () => { + const fn = vi.fn(async (url, init) => ({ + ok: true, + status: 200, + json: () => + new Promise((_, reject) => + init.signal.addEventListener("abort", () => reject(init.signal.reason)), + ), + })); + const client = mkClient({ timeoutMs: 20 }, fn); await expect(client.searchRead("res.partner")).rejects.toMatchObject({ - name: "TransportError", + code: "ODOO_TRANSPORT_FAILED", + message: expect.stringContaining("timed out after 20ms"), }); }); @@ -314,25 +239,6 @@ describe("OdooClient", () => { expect(kwargs).toEqual({ attributes: ["type", "required"] }); }); - it("clearFieldsCache(model) drops only that model's entries", async () => { - const { fn, calls } = makeFetch([ - { json: { result: 1 } }, - { json: { result: { id: 1 } } }, // res.partner - { json: { result: { id: 1 } } }, // sale.order - { json: { result: { id: 1 } } }, // res.partner after clear - ]); - const client = mkClient({}, fn); - await client.fieldsGet("res.partner"); - await client.fieldsGet("sale.order"); - client.clearFieldsCache("res.partner"); - - await client.fieldsGet("res.partner"); // re-fetch - await client.fieldsGet("sale.order"); // cache hit, no new call - - const execs = calls.filter((c) => c.body.params.method === "execute_kw"); - expect(execs).toHaveLength(3); - }); - it("respects configured timeoutMs", async () => { const client = mkClient({ timeoutMs: 1234 }); expect(client.timeoutMs).toBe(1234); @@ -354,7 +260,8 @@ describe("OdooClient", () => { expect(kwargs).toEqual({ limit: 100 }); }); - it("nameSearch maps domain→args kwarg and returns tuple list", async () => { + // Bug fixed: Odoo 19 renamed name_search's `args` kwarg to `domain`; positional works on 18 and 19. + it("nameSearch sends name and domain positionally", async () => { const { fn, calls } = makeFetch([ { json: { result: 1 } }, { json: { result: [[10, "Acme"], [11, "Acme Logistics"]] } }, @@ -370,13 +277,8 @@ describe("OdooClient", () => { const [, , , model, method, args, kwargs] = calls[1].body.params.args; expect(model).toBe("res.partner"); expect(method).toBe("name_search"); - expect(args).toEqual([]); - expect(kwargs).toEqual({ - name: "acme", - args: [["customer_rank", ">", 0]], - operator: "ilike", - limit: 5, - }); + expect(args).toEqual(["acme", [["customer_rank", ">", 0]]]); + expect(kwargs).toEqual({ operator: "ilike", limit: 5 }); }); it("readGroup sends domain/aggregates/groupby as positional and the rest as kwargs", async () => { diff --git a/odoo/test/unit/clientRegistry.test.js b/odoo/test/unit/clientRegistry.test.js deleted file mode 100644 index df7f9a9..0000000 --- a/odoo/test/unit/clientRegistry.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { ClientRegistry } from "../../lib/clientRegistry.js"; - -const CFG = { - prod: { - name: "prod", - url: "https://erp.example.com", - db: "prod", - username: "admin", - authType: "apikey", - secret: "k", - timeoutMs: 60000, - }, - staging: { - name: "staging", - url: "https://staging.example.com", - db: "staging", - username: "admin", - authType: "password", - secret: "p", - timeoutMs: 120000, - }, -}; - -describe("ClientRegistry", () => { - it("constructs one client per configured connection", () => { - const r = new ClientRegistry(CFG); - expect(r.has("prod")).toBe(true); - expect(r.has("staging")).toBe(true); - expect(r.has("missing")).toBe(false); - }); - - it("list() exposes describe() output without secrets", () => { - const r = new ClientRegistry(CFG); - const list = r.list(); - expect(list).toHaveLength(2); - for (const item of list) { - expect(item).not.toHaveProperty("secret"); - expect(item.authType).toBeDefined(); - expect(item.authenticated).toBe(false); - expect(typeof item.timeoutMs).toBe("number"); - } - }); - - it("propagates per-connection timeoutMs to the underlying client", () => { - const r = new ClientRegistry(CFG); - expect(r.get("prod").timeoutMs).toBe(60000); - expect(r.get("staging").timeoutMs).toBe(120000); - }); - - it("get() returns the client for a known name", () => { - const r = new ClientRegistry(CFG); - const c = r.get("prod"); - expect(c.name).toBe("prod"); - expect(c.db).toBe("prod"); - }); - - it("get() throws UnknownConnectionError with the available list", () => { - const r = new ClientRegistry(CFG); - expect(() => r.get("nope")).toThrowError(/Unknown connection "nope"/); - }); - - it("get() with no connections gives an actionable error", () => { - const r = new ClientRegistry({}); - expect(() => r.get("prod")).toThrowError(/No Odoo connections configured/); - }); -}); diff --git a/odoo/test/unit/server.test.js b/odoo/test/unit/server.test.js new file mode 100644 index 0000000..bc63d4f --- /dev/null +++ b/odoo/test/unit/server.test.js @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { OdooMCPServer } from "../../mcpServer.js"; +import { registerTools } from "../../lib/toolHandlers.js"; +import { OdooError } from "../../lib/errors.js"; +import { INSTRUCTIONS } from "../../lib/instructions.js"; + +beforeAll(() => vi.spyOn(console, "error").mockImplementation(() => {})); +afterAll(() => vi.restoreAllMocks()); + +async function connect(server) { + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(a); + const client = new Client({ name: "test", version: "0.0.0" }); + await client.connect(b); + const call = async (name, args = {}) => { + const r = await client.callTool({ name, arguments: args }); + const text = r.content[0].text; + return r.isError ? { error: text } : JSON.parse(text); + }; + return { client, call }; +} + +/** registerTools over a single stub client named "prod". */ +function withStub(stub) { + const server = new McpServer({ name: "t", version: "0" }); + registerTools(server, new Map([["prod", stub]])); + return connect(server); +} + +describe("tools", () => { + it("exposes 10 strict tools with read/destructive annotations", async () => { + const { client } = await withStub({}); + const { tools } = await client.listTools(); + const byName = Object.fromEntries(tools.map((t) => [t.name, t])); + expect(Object.keys(byName).sort()).toEqual([ + "call_method", + "create", + "fields_get", + "list_connections", + "name_search", + "read_group", + "search_count", + "search_read", + "unlink", + "write", + ]); + for (const n of ["list_connections", "fields_get", "search_read", "search_count", "name_search", "read_group"]) { + expect(byName[n].annotations.readOnlyHint).toBe(true); + } + for (const n of ["write", "unlink", "call_method"]) { + expect(byName[n].annotations.destructiveHint).toBe(true); + } + expect(byName.search_read.inputSchema.additionalProperties).toBe(false); + }); + + it("keeps the instructions under the ~2 KB hosts truncate at", () => { + expect(INSTRUCTIONS.length).toBeLessThan(2000); + }); + + it("rejects unknown arguments instead of silently ignoring them", async () => { + const searchRead = vi.fn(); + const { call } = await withStub({ searchRead }); + const r = await call("search_read", { connection: "prod", model: "res.partner", filters: [] }); + expect(r.error).toMatch(/Input validation error.*filters/s); + expect(searchRead).not.toHaveBeenCalled(); + }); + + it("search_read defaults limit to 100 and flags a full page", async () => { + const rows = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })); + const searchRead = vi.fn().mockResolvedValueOnce(rows).mockResolvedValueOnce([{ id: 1 }]); + const { call } = await withStub({ searchRead }); + + const full = await call("search_read", { connection: "prod", model: "res.partner" }); + expect(searchRead).toHaveBeenLastCalledWith("res.partner", { + domain: undefined, + fields: undefined, + limit: 100, + offset: undefined, + order: undefined, + }); + expect(full).toMatchObject({ count: 100, limit_reached: true }); + + const short = await call("search_read", { connection: "prod", model: "res.partner", limit: 5 }); + expect(short).toEqual({ model: "res.partner", count: 1, records: [{ id: 1 }] }); + }); + + it("create returns {id} for a dict and {ids} for an array", async () => { + const create = vi.fn().mockResolvedValueOnce(7).mockResolvedValueOnce([8, 9]); + const { call } = await withStub({ create }); + expect(await call("create", { connection: "prod", model: "res.partner", values: { name: "A" } })).toEqual({ + model: "res.partner", + id: 7, + }); + expect( + await call("create", { connection: "prod", model: "res.partner", values: [{ name: "B" }, { name: "C" }] }), + ).toEqual({ model: "res.partner", ids: [8, 9] }); + }); + + it("forwards name_search, read_group and call_method arguments", async () => { + const stub = { + nameSearch: vi.fn().mockResolvedValue([[1, "Acme"]]), + readGroup: vi.fn().mockResolvedValue([{ user_id: [5, "Alice"] }]), + callKw: vi.fn().mockResolvedValue(true), + }; + const { call } = await withStub(stub); + + await call("name_search", { connection: "prod", model: "res.partner", name: "ac", limit: 5 }); + expect(stub.nameSearch).toHaveBeenCalledWith("res.partner", { + name: "ac", + domain: undefined, + operator: undefined, + limit: 5, + }); + + const g = await call("read_group", { + connection: "prod", + model: "sale.order", + aggregates: ["amount_total:sum"], + groupby: ["user_id"], + lazy: false, + }); + expect(stub.readGroup).toHaveBeenCalledWith("sale.order", { + aggregates: ["amount_total:sum"], + groupby: ["user_id"], + lazy: false, + }); + expect(g.count).toBe(1); + + const m = await call("call_method", { + connection: "prod", + model: "sale.order", + method: "action_confirm", + args: [[17]], + }); + expect(stub.callKw).toHaveBeenCalledWith("sale.order", "action_confirm", [[17]], undefined); + expect(m).toEqual({ model: "sale.order", method: "action_confirm", result: true }); + }); + + it("returns [CODE] errors for unknown connections and Odoo failures", async () => { + const unlink = vi.fn().mockRejectedValue(new OdooError("ODOO_USER_ERROR", "posted")); + const { call } = await withStub({ unlink }); + expect((await call("unlink", { connection: "nope", model: "res.partner", ids: [1] })).error).toBe( + '[ODOO_UNKNOWN_CONNECTION] Unknown connection "nope". Available: prod', + ); + expect((await call("unlink", { connection: "prod", model: "account.move", ids: [1] })).error).toBe( + "[ODOO_USER_ERROR] posted", + ); + }); +}); + +describe("OdooMCPServer", () => { + it("builds clients from env and never lists secrets", async () => { + const { server } = new OdooMCPServer({ + env: { + ODOO_PROD_URL: "https://erp.example.com/", + ODOO_PROD_DB: "production", + ODOO_PROD_USERNAME: "admin", + ODOO_PROD_API_KEY: "k-secret", + }, + }); + const { client, call } = await connect(server); + const { connections } = await call("list_connections"); + expect(connections).toEqual([ + { + name: "prod", + url: "https://erp.example.com", + db: "production", + username: "admin", + authType: "apikey", + authenticated: false, + timeoutMs: 60000, + }, + ]); + expect(JSON.stringify(connections)).not.toContain("k-secret"); + expect(client.getInstructions()).toBe(INSTRUCTIONS); + }); + + it("explains how to configure when no connection exists", async () => { + const { server } = new OdooMCPServer({ env: {} }); + const { call } = await connect(server); + expect((await call("search_count", { connection: "prod", model: "res.partner" })).error).toMatch( + /^\[ODOO_UNKNOWN_CONNECTION\] No Odoo connections configured/, + ); + }); +}); diff --git a/odoo/test/unit/toolHandlers.test.js b/odoo/test/unit/toolHandlers.test.js deleted file mode 100644 index d4f216f..0000000 --- a/odoo/test/unit/toolHandlers.test.js +++ /dev/null @@ -1,274 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { ToolHandlers } from "../../lib/toolHandlers.js"; -import { UnknownConnectionError } from "../../lib/errors.js"; - -function makeRegistry(stubClient) { - return { - list() { - return [ - { name: "prod", url: "https://erp.example.com", db: "p", username: "admin", authType: "apikey", authenticated: false }, - ]; - }, - get(name) { - if (name !== "prod") { - throw new UnknownConnectionError(`Unknown connection "${name}"`); - } - return stubClient; - }, - has: (n) => n === "prod", - }; -} - -function payload(result) { - return JSON.parse(result.content[0].text); -} - -describe("ToolHandlers", () => { - it("toolDescriptors exposes 10 tools with required-field metadata", () => { - const t = new ToolHandlers(makeRegistry({})); - const tools = t.toolDescriptors(); - const names = tools.map((d) => d.name).sort(); - expect(names).toEqual( - [ - "call_method", - "create", - "fields_get", - "list_connections", - "name_search", - "read_group", - "search_count", - "search_read", - "unlink", - "write", - ].sort(), - ); - for (const d of tools) { - expect(d.description.length).toBeGreaterThan(20); - expect(d.inputSchema.type).toBe("object"); - expect(d.inputSchema.additionalProperties).toBe(false); - } - }); - - it("list_connections returns the registry summary", async () => { - const t = new ToolHandlers(makeRegistry({})); - const res = await t.dispatch("list_connections", {}); - expect(payload(res).connections[0].name).toBe("prod"); - }); - - it("search_read forwards to client.searchRead", async () => { - const client = { searchRead: vi.fn().mockResolvedValue([{ id: 1, name: "A" }]) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("search_read", { - connection: "prod", - model: "res.partner", - domain: [["active", "=", true]], - fields: ["name"], - limit: 5, - }); - expect(client.searchRead).toHaveBeenCalledWith("res.partner", { - domain: [["active", "=", true]], - fields: ["name"], - limit: 5, - offset: undefined, - order: undefined, - }); - expect(payload(res)).toEqual({ model: "res.partner", count: 1, records: [{ id: 1, name: "A" }] }); - }); - - it("create returns {id} for dict and {ids} for array", async () => { - const client = { create: vi.fn() }; - client.create.mockResolvedValueOnce(7).mockResolvedValueOnce([8, 9]); - const t = new ToolHandlers(makeRegistry(client)); - - const single = await t.dispatch("create", { - connection: "prod", - model: "res.partner", - values: { name: "X" }, - }); - expect(payload(single)).toEqual({ model: "res.partner", id: 7 }); - - const bulk = await t.dispatch("create", { - connection: "prod", - model: "res.partner", - values: [{ name: "Y" }, { name: "Z" }], - }); - expect(payload(bulk)).toEqual({ model: "res.partner", ids: [8, 9] }); - }); - - it("write forwards ids + values to client.write", async () => { - const client = { write: vi.fn().mockResolvedValue(true) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("write", { - connection: "prod", - model: "res.partner", - ids: [1, 2], - values: { active: false }, - }); - expect(client.write).toHaveBeenCalledWith("res.partner", [1, 2], { active: false }); - expect(payload(res).success).toBe(true); - }); - - it("unlink forwards ids to client.unlink", async () => { - const client = { unlink: vi.fn().mockResolvedValue(true) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("unlink", { - connection: "prod", - model: "res.partner", - ids: [42], - }); - expect(client.unlink).toHaveBeenCalledWith("res.partner", [42]); - expect(payload(res).success).toBe(true); - }); - - it("search_count forwards domain + limit", async () => { - const client = { searchCount: vi.fn().mockResolvedValue(7) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("search_count", { - connection: "prod", - model: "res.partner", - domain: [["country_id.code", "=", "VN"]], - limit: 100, - }); - expect(client.searchCount).toHaveBeenCalledWith( - "res.partner", - [["country_id.code", "=", "VN"]], - 100, - ); - expect(payload(res)).toEqual({ model: "res.partner", count: 7 }); - }); - - it("search_count with no domain forwards []", async () => { - const client = { searchCount: vi.fn().mockResolvedValue(99) }; - const t = new ToolHandlers(makeRegistry(client)); - await t.dispatch("search_count", { connection: "prod", model: "res.partner" }); - expect(client.searchCount).toHaveBeenCalledWith("res.partner", [], undefined); - }); - - it("name_search forwards all opts", async () => { - const client = { - nameSearch: vi.fn().mockResolvedValue([[1, "Acme"], [2, "Acme Logistics"]]), - }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("name_search", { - connection: "prod", - model: "res.partner", - name: "acme", - domain: [["customer_rank", ">", 0]], - operator: "ilike", - limit: 5, - }); - expect(client.nameSearch).toHaveBeenCalledWith("res.partner", { - name: "acme", - domain: [["customer_rank", ">", 0]], - operator: "ilike", - limit: 5, - }); - expect(payload(res)).toEqual({ - model: "res.partner", - results: [[1, "Acme"], [2, "Acme Logistics"]], - }); - }); - - it("read_group forwards aggregates + groupby + lazy flag", async () => { - const groups = [ - { - user_id: [5, "Alice"], - "date_order:month": "January 2026", - amount_total: 125000, - id: 42, - }, - ]; - const client = { readGroup: vi.fn().mockResolvedValue(groups) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("read_group", { - connection: "prod", - model: "sale.order", - domain: [["state", "in", ["sale", "done"]]], - aggregates: ["amount_total:sum", "id:count"], - groupby: ["user_id", "date_order:month"], - lazy: false, - orderby: "user_id, date_order", - limit: 100, - }); - expect(client.readGroup).toHaveBeenCalledWith("sale.order", { - domain: [["state", "in", ["sale", "done"]]], - aggregates: ["amount_total:sum", "id:count"], - groupby: ["user_id", "date_order:month"], - lazy: false, - orderby: "user_id, date_order", - limit: 100, - offset: undefined, - }); - expect(payload(res)).toMatchObject({ - model: "sale.order", - count: 1, - groups, - }); - }); - - it("call_method forwards args and kwargs", async () => { - const client = { callKw: vi.fn().mockResolvedValue(true) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("call_method", { - connection: "prod", - model: "sale.order", - method: "action_confirm", - args: [[1]], - kwargs: { context: { lang: "en_US" } }, - }); - expect(client.callKw).toHaveBeenCalledWith( - "sale.order", - "action_confirm", - [[1]], - { context: { lang: "en_US" } }, - ); - expect(payload(res).result).toBe(true); - }); - - it("fields_get forwards fields + attributes as opts", async () => { - const client = { fieldsGet: vi.fn().mockResolvedValue({ name: { type: "char" } }) }; - const t = new ToolHandlers(makeRegistry(client)); - const res = await t.dispatch("fields_get", { - connection: "prod", - model: "res.partner", - fields: ["name", "email"], - attributes: ["string", "type"], - }); - expect(client.fieldsGet).toHaveBeenCalledWith("res.partner", { - fields: ["name", "email"], - attributes: ["string", "type"], - }); - expect(payload(res).fields.name.type).toBe("char"); - }); - - it("fields_get works without optional filters", async () => { - const client = { fieldsGet: vi.fn().mockResolvedValue({ name: { type: "char" } }) }; - const t = new ToolHandlers(makeRegistry(client)); - await t.dispatch("fields_get", { connection: "prod", model: "res.partner" }); - expect(client.fieldsGet).toHaveBeenCalledWith("res.partner", { - fields: undefined, - attributes: undefined, - }); - }); - - it("returns a formatted MCP error on validation failure", async () => { - const t = new ToolHandlers(makeRegistry({})); - const res = await t.dispatch("search_read", { connection: "PROD", model: "res.partner" }); - expect(res.isError).toBe(true); - expect(res.content[0].text).toMatch(/ODOO_INPUT_INVALID/); - }); - - it("returns a formatted MCP error on unknown connection", async () => { - const t = new ToolHandlers(makeRegistry({})); - const res = await t.dispatch("fields_get", { connection: "missing", model: "res.partner" }); - expect(res.isError).toBe(true); - expect(res.content[0].text).toMatch(/ODOO_UNKNOWN_CONNECTION/); - }); - - it("returns a formatted MCP error on unknown tool", async () => { - const t = new ToolHandlers(makeRegistry({})); - const res = await t.dispatch("nope", {}); - expect(res.isError).toBe(true); - expect(res.content[0].text).toMatch(/Unknown tool/); - }); -}); diff --git a/odoo/test/unit/validators.test.js b/odoo/test/unit/validators.test.js deleted file mode 100644 index 7ee3ac1..0000000 --- a/odoo/test/unit/validators.test.js +++ /dev/null @@ -1,327 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - SearchReadInputSchema, - SearchCountInputSchema, - NameSearchInputSchema, - ReadGroupInputSchema, - CreateInputSchema, - WriteInputSchema, - UnlinkInputSchema, - CallMethodInputSchema, - FieldsGetInputSchema, - parseOrThrow, -} from "../../lib/validators.js"; - -describe("validators", () => { - describe("SearchReadInputSchema", () => { - it("accepts a minimal valid input", () => { - const r = SearchReadInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - }); - expect(r.success).toBe(true); - }); - - it("accepts a full domain + paging", () => { - const r = SearchReadInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - domain: [["state", "=", "sale"], "&", ["amount_total", ">", 100]], - fields: ["name", "amount_total"], - limit: 10, - offset: 0, - order: "date_order desc", - }); - expect(r.success).toBe(true); - }); - - it("rejects invalid model name", () => { - const r = SearchReadInputSchema.safeParse({ connection: "prod", model: "Sale.Order" }); - expect(r.success).toBe(false); - }); - - it("rejects invalid connection name", () => { - const r = SearchReadInputSchema.safeParse({ connection: "PROD", model: "res.partner" }); - expect(r.success).toBe(false); - }); - - it("rejects unknown property (strict mode)", () => { - const r = SearchReadInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - extra: 1, - }); - expect(r.success).toBe(false); - }); - - it("rejects limit above 10000", () => { - const r = SearchReadInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - limit: 10001, - }); - expect(r.success).toBe(false); - }); - }); - - describe("CreateInputSchema", () => { - it("accepts a single record dict", () => { - const r = CreateInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - values: { name: "Acme" }, - }); - expect(r.success).toBe(true); - }); - - it("accepts an array of records", () => { - const r = CreateInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - values: [{ name: "Acme" }, { name: "Initech" }], - }); - expect(r.success).toBe(true); - }); - - it("rejects empty array of records", () => { - const r = CreateInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - values: [], - }); - expect(r.success).toBe(false); - }); - }); - - describe("WriteInputSchema", () => { - it("accepts valid input", () => { - const r = WriteInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - ids: [1, 2], - values: { active: false }, - }); - expect(r.success).toBe(true); - }); - - it("rejects empty ids array", () => { - const r = WriteInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - ids: [], - values: { active: false }, - }); - expect(r.success).toBe(false); - }); - - it("rejects non-positive ids", () => { - const r = WriteInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - ids: [0, -1], - values: { active: false }, - }); - expect(r.success).toBe(false); - }); - }); - - describe("UnlinkInputSchema", () => { - it("accepts valid input", () => { - const r = UnlinkInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - ids: [10], - }); - expect(r.success).toBe(true); - }); - }); - - describe("CallMethodInputSchema", () => { - it("accepts minimal input", () => { - const r = CallMethodInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - method: "action_confirm", - }); - expect(r.success).toBe(true); - }); - - it("accepts args and kwargs", () => { - const r = CallMethodInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - method: "action_confirm", - args: [[1]], - kwargs: { context: { lang: "en_US" } }, - }); - expect(r.success).toBe(true); - }); - - it("rejects bad method name", () => { - const r = CallMethodInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - method: "1bad", - }); - expect(r.success).toBe(false); - }); - }); - - describe("FieldsGetInputSchema", () => { - it("accepts attributes array", () => { - const r = FieldsGetInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - attributes: ["string", "type", "required"], - }); - expect(r.success).toBe(true); - }); - - it("accepts fields whitelist", () => { - const r = FieldsGetInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - fields: ["name", "state"], - }); - expect(r.success).toBe(true); - }); - - it("rejects empty attributes array", () => { - const r = FieldsGetInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - attributes: [], - }); - expect(r.success).toBe(false); - }); - - it("rejects empty fields array", () => { - const r = FieldsGetInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - fields: [], - }); - expect(r.success).toBe(false); - }); - }); - - describe("SearchCountInputSchema", () => { - it("accepts minimal input", () => { - const r = SearchCountInputSchema.safeParse({ connection: "prod", model: "res.partner" }); - expect(r.success).toBe(true); - }); - - it("accepts domain + limit", () => { - const r = SearchCountInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - domain: [["active", "=", true]], - limit: 1, - }); - expect(r.success).toBe(true); - }); - - it("rejects unknown keys", () => { - const r = SearchCountInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - extra: 1, - }); - expect(r.success).toBe(false); - }); - }); - - describe("NameSearchInputSchema", () => { - it("accepts minimal input", () => { - const r = NameSearchInputSchema.safeParse({ connection: "prod", model: "res.partner" }); - expect(r.success).toBe(true); - }); - - it("accepts full input", () => { - const r = NameSearchInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - name: "acme", - domain: [["customer_rank", ">", 0]], - operator: "ilike", - limit: 5, - }); - expect(r.success).toBe(true); - }); - - it("rejects unknown operator", () => { - const r = NameSearchInputSchema.safeParse({ - connection: "prod", - model: "res.partner", - operator: "regex", - }); - expect(r.success).toBe(false); - }); - }); - - describe("ReadGroupInputSchema", () => { - it("accepts minimal valid input", () => { - const r = ReadGroupInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - aggregates: ["amount_total:sum"], - groupby: ["user_id"], - }); - expect(r.success).toBe(true); - }); - - it("accepts date granularity + lazy=false", () => { - const r = ReadGroupInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - domain: [["state", "=", "sale"]], - aggregates: ["amount_total:sum", "id:count"], - groupby: ["user_id", "date_order:month"], - lazy: false, - orderby: "user_id, date_order", - limit: 100, - }); - expect(r.success).toBe(true); - }); - - it("rejects empty aggregates", () => { - const r = ReadGroupInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - aggregates: [], - groupby: ["user_id"], - }); - expect(r.success).toBe(false); - }); - - it("rejects empty groupby", () => { - const r = ReadGroupInputSchema.safeParse({ - connection: "prod", - model: "sale.order", - aggregates: ["amount_total:sum"], - groupby: [], - }); - expect(r.success).toBe(false); - }); - }); - - describe("parseOrThrow", () => { - it("returns parsed value on success", async () => { - const r = await parseOrThrow( - UnlinkInputSchema, - { connection: "prod", model: "res.partner", ids: [1] }, - "unlink", - ); - expect(r.connection).toBe("prod"); - }); - - it("throws InputValidationError on failure with composed message", async () => { - await expect( - parseOrThrow(UnlinkInputSchema, { connection: "PROD" }, "unlink"), - ).rejects.toMatchObject({ - name: "InputValidationError", - code: "ODOO_INPUT_INVALID", - }); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a8fdb2..6c1b2c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,7 +97,7 @@ importers: specifier: ^1.31.0 version: 1.31.0(zod@3.25.76) zod: - specifier: ^3.23.8 + specifier: ^3.25.0 version: 3.25.76 devDependencies: '@vitest/coverage-v8':