feat: route long single-field payloads through the local filesystem - #55
feat: route long single-field payloads through the local filesystem#55ERnsTL wants to merge 10 commits into
Conversation
…v1.3.0)
LLMs routinely mistransform long HTML / source code / rich text that
round-trips through the JSON-RPC envelope (double-escaped quotes,
escaped newlines, embedded Unicode). Two new MCP tools let agents
move those payloads out of band: only the SHA-256 + size fingerprint
crosses the wire, the real bytes stay on disk. Destructive write
goes through the same preview/execute + confirm gate as every other
write.
- read_field_to_file (read domain): stream one field's value to a
local file. Honors field ACL (redacted values become
[REDACTED by field ACL] placeholder, response flags it via
field_was_redacted). Text defaults to UTF-8; binary defaults to
base64. Symlink-safe, refuses overwrite, absolute-path-only,
O_NOFOLLOW on the create fd.
- write_field_from_file (write domain): two-phase preview/execute
flow mirroring chatter_post. Preview returns a token carrying
only sha256:<hex>:<size>; execute re-reads the file, re-checks
the hash (catches tamper + TOCTOU), fetches live fields_get
metadata (refuses readonly=True), then routes through
confirm=true + ODOO_MCP_ENABLE_WRITES=1. Binary round-trip
via base64; otherwise UTF-8.
- ODOO_MCP_FIELD_FILE_ROOTS allow-list: os.pathsep-separated
absolute directories. Hardened pattern from
ODOO_MCP_ATTACHMENT_UPLOAD_ROOTS: Path.resolve()d before the
containment check, so .. traversal and symlink escapes cannot
reach outside the operator's allow-list. Per-call override via
the file_root argument on both tools.
- Fail-closed default with actionable error: with no env var and
no per-call override, both tools reject every call. The error
names the env var, lists platform-specific safe defaults
(~/.cache/odoo-mcp/field-files on Linux/macOS,
%LOCALAPPDATA%\odoo-mcp\Cache\field-files on Windows), and
explicitly warns against /tmp (typically world-readable — long
field payloads would leak to other local users / processes).
- Runtime posture in health_check.runtime.field_file_roots
(env var name + count + resolved paths, no secrets).
- docs/field-file-io.md: exhaustive operator guide (config,
examples for HTML comments, binary attachments, long internal
notes; full threat-model table).
- CHANGELOG 1.3.0 entry. pyproject version 1.2.1 -> 1.3.0.
Fixed: write_field_from_file execute_kw argument shape. The initial
implementation called
odoo.execute_method(model, "write", [[ids], {vals}])
which OdooClient.execute_method(self, model, method, *args, **kwargs)
forwarded as a single positional arg, so Odoo's XML-RPC dispatcher
saw execute_kw("project.task", "write", [[ids], {vals}]) and unpacked
it as ProjectTask.write(*[[ids], {vals}]) -> write([ids], {vals}),
faulting with `missing 1 required positional argument: 'vals'`.
Now called as `odoo.execute_method(model, "write", [ids], {vals})`
so ids + vals arrive as two separate positional arguments (mirrors
execute_approved_write). New regression test
test_write_field_from_file_does_not_pack_args_into_single_list; the
execute_method test stub now records the unpacked *args form,
matching OdooClient's signature, so a re-introduction of the
nested-list form fires immediately.
932 tests pass (was 909). Tool count 41 -> 43 (read_field_to_file,
write_field_from_file). No behavior change for existing tools.
| raise ValueError( | ||
| f"file_root override must be an absolute path; got {file_root!r}" | ||
| ) | ||
| return candidate |
There was a problem hiding this comment.
[bug] resolve_file_root_override accepts any absolute file_root from the tool caller and never checks it against ODOO_MCP_FIELD_FILE_ROOTS. Because MCP tool arguments are agent-controlled, a prompt-injected agent can pass file_root="/" (or file_root="/home/user") and then (a) write_field_from_file with input_path pointing at SSH keys / secrets to exfiltrate them into an Odoo field, or (b) read_field_to_file with output_path under any writable location that does not already exist. This is the exact class of arbitrary local-file read the attachment _from_path path deliberately fails closed against (restrict_attachment_upload_path has no per-call root override). Docs claim the override "can never widen the operator's allow-list" (docs/field-file-io.md), which is false as implemented — and the bypass works even when the env allow-list is set.
Suggestion: Require ODOO_MCP_FIELD_FILE_ROOTS always (no free-form escape hatch). Treat file_root only as a selector among configured roots (must equal one of them after resolve), mirroring multi-root selection rather than inventing a new root. If a true per-call operator override is needed, gate it with a separate env flag that is not agent-reachable, or drop the argument entirely.
| "local users and processes. Use a per-user directory with mode " | ||
| "0700 instead." | ||
| ) | ||
| return roots[0] |
There was a problem hiding this comment.
[bug] When file_root is omitted, resolve_file_root_override returns only roots[0], and restrict_field_file_path then containment-checks against that single path. Operator configs like ODOO_MCP_FIELD_FILE_ROOTS="/srv/a:/srv/b" reject every absolute path under /srv/b even though docs/CHANGELOG/README advertise a multi-root allow-list ("must sit inside one of the configured roots"). Attachment upload correctly uses any(candidate == root or _is_relative_to(...)) over all roots; this path does not.
Suggestion: When no override is supplied, validate the candidate against all configured_field_file_roots() (return the matching root). Keep file_root only as an optional selector that must itself be one of those roots.
| against ``/tmp``. | ||
| """ | ||
| if file_root: | ||
| candidate = Path(file_root).expanduser().resolve(strict=False) |
There was a problem hiding this comment.
[bug] Absolute-path validation for file_root runs after Path(...).expanduser().resolve(strict=False). resolve() always yields an absolute path (relative inputs are joined to cwd), so if not candidate.is_absolute() is dead code. Relative file_root values such as "scratch" silently become $CWD/scratch and are accepted. The same PR carefully documents and implements pre-resolve absolute checks for the file path itself a few lines below — file_root missed that fix.
Suggestion: Check absolute-ness on the expanduser'd path before resolve(), matching restrict_field_file_path for raw_path.
| if encoding is None: | ||
| chosen_encoding = "base64" if declared_binary else "utf-8" | ||
| else: | ||
| chosen_encoding = encoding.strip().lower() |
There was a problem hiding this comment.
[bug] Comments and docs/field-file-io.md state that encoding="base64" on a non-binary field must raise a clear validation error. Implementation only does chosen_encoding = encoding.strip().lower() with no declared_binary guard, then base64.b64decode(..., validate=False) on whatever string Odoo returned. That can write silently corrupted/garbage bytes for HTML/text fields (and validate=False is especially permissive). The comment two lines above ("cannot force base64 on a non-binary type") describes intent that the code never enforces.
Suggestion: After resolving chosen_encoding, if it is "base64" and not declared_binary, raise ValueError naming the field and fields_get.type. Prefer validate=True on the decode path for binary fields as well.
| Field( | ||
| description=( | ||
| "Name of the field to set. Binary fields are read as raw bytes; " | ||
| "for non-binary fields, set encoding='base64' to decode a base64 " |
There was a problem hiding this comment.
[suggestion] The field parameter description says: for non-binary fields, "set encoding='base64' to decode a base64 blob from disk." Execute path does the opposite for encoding="base64": it base64.b64encode(re_read) so on-disk content is treated as raw bytes and encoded for Odoo's binary wire format. Agents following the tool schema will double-encode base64 text files.
Suggestion: Reword to match behavior, e.g. "encoding='base64' means the file holds raw bytes that will be base64-encoded for Odoo (default for binary fields); 'utf-8' means the file is text written as a Unicode string."
| resulting fd to close the TOCTOU window — see ``_read_field_file`` / | ||
| ``_write_field_file`` in ``tools_write.py``. | ||
| """ | ||
| resolved_root = resolve_file_root_override(file_root) |
There was a problem hiding this comment.
[nit] Docstring for restrict_field_file_path tells callers to see _read_field_file / _write_field_file in tools_write.py. _write_field_file does not exist (write-to-disk lives inline in read_field_to_file in tools_read.py).
Suggestion: Point at _read_field_file (tools_write) and the O_CREAT|O_EXCL|O_NOFOLLOW block in read_field_to_file (tools_read).
- Wrap all Optional[List[...]], List[...], and Optional[Dict[...]] parameters in Annotated[..., Field(description=...)] so Pydantic emits a clean JSON array/object schema instead of the {"item": [...]} wrapper that caused MCP clients to reject calls with `Input should be a valid list [type=list_type, input_value={'item': [...]}, input_type=dict]`.
- Fix `get_model_fields`, `search_records`, `read_record`, `aggregate_records` in `src/odoo_mcp/tools_read.py`.
- Fix `index_knowledge` in `src/odoo_mcp/tools_knowledge.py`.
- Fix `data_quality_report` in `src/odoo_mcp/tools_data_quality.py`.
- Fix `generate_json2_payload`, `inspect_model_relationships`, `analyze_upgrade_log`, `fit_gap_report`, `scan_addons_source`, `build_domain` in `src/odoo_mcp/tools_diagnostics.py`.
- Fix `preview_write`, `validate_write`, `chatter_post` in `src/odoo_mcp/tools_write.py`.
- Fix `search_across_instances`, `aggregate_across_instances` in `src/odoo_mcp/tools_cross_instance.py`.
- Bump version 1.3.0 -> 1.3.1 in `pyproject.toml` (patch release, no behaviour change at runtime).
- Add `[1.3.1] - 2026-07-21` entry to `CHANGELOG.md` documenting the JSON-schema wrapping fix.
- Verified end-to-end: 932/932 pytest tests pass with the new `mcp>=1.27,<2` (installed 1.28.1); direct `inputSchema` inspection of the running server confirms list/dict parameters now export as proper JSON arrays and objects.
|
@tuanle96 Thanks for the feedback - I will resolve these points you have mentioned. |
- file_root: treat as selector among configured roots (cannot widen allow-list) - file_root: always require ODOO_MCP_FIELD_FILE_ROOTS; no free-form escape hatch - file_root: check absolute-ness before resolve() (kills $CWD/scratch bypass) - restrict_field_file_path: validate against all roots when no override - restrict_field_file_path: fix docstring (points at correct functions) - read_field_to_file: reject encoding="base64" on non-binary fields - read_field_to_file: switch base64 decode to validate=True - write_field_from_file: rewrite field/encoding descriptions to match behavior - tests: add 5 regression tests + update 2 existing tests - docs: update field-file-io.md (selector semantics, no file_root remediation)
|
Thanks @tuanle96 for the thorough review - all 6 comments addressed. Summary below; full details in the commit. 🔒 Security fix —
Fix:
🐛 Validation gap - The documented contract said "cannot force base64 on a non-binary type" but the code only did Fix:
📝 Docs gap - The description said "set Fix: rewrote the 🧪 Tests
📚 Docs
Verification:
|
|
@tuanle96 New commit fixing validation errors when using certain tools. Applied the solution already used in many other tools. SummaryFixes Pydantic schema generation for list/dict tool parameters across 19 MCP tools. Parameters declared as WhyBefore the fix, agents could not pass array arguments to several read, write, and diagnostics tools - every call returned a Changes
No behavior change at runtime - only the JSON-Schema surface for Environment / Dependency NoteThe fix relies on the Verification
|
|
@tuanle96 security comments fixed, fixed several tools (datastructure wrapping issue) and resolved merge conflicts. |
|
New fix release. SummaryPatch release What changed
Files touched11 files, 401 +/36 -. Compatibility
Test evidenceEnd-to-end smoke through the deployed Checklist
|
…omain parsing
The four most common agent-side tool errors — ``measures=""`` (Pydantic
type mismatch), ``measures=["__count"]`` (Odoo rejects the auto-count
column as a measure), ``domain="def x():"`` (malformed domain) and
``instance="ghost"`` (unknown instance) — used to leak raw Pydantic
stack traces or hit Odoo's error path before any agent-friendly envelope
ran. This change delivers the friendly ``{"success": false, "tool": ...,
"error": "Invalid input: ..."}`` envelope on every one of them, removes
a CPython runpy RuntimeWarning by hand, and bumps 1.3.1 → 1.3.2 per
semver. No public-API breakage; 944 tests pass; 0 warnings.
What changed
- Friendly envelope on Pydantic argument validation. FastMCP validates
annotated tool parameters via Pydantic BEFORE the tool body runs
(``mcp.server.fastmcp.utilities.func_metadata.call_fn_with_arg_validation``),
and converts any ``ValidationError`` to ``mcp.server.fastmcp.exceptions.ToolError``
whose ``__cause__`` is the original error. The bound method
``FastMCP.call_tool`` is captured by reference at instance init via
``self._mcp_server.call_tool(validate_input=False)(self.call_tool)``, and
any post-init monkey-patch on ``mcp.call_tool`` or ``mcp._tool_manager.call_tool``
bypasses the JSON-RPC dispatch path. The fix is a tiny
``_TranslationAwareFastMCP(FastMCP)`` subclass in ``src/odoo_mcp/server_core.py``
that overrides ``call_tool``; FastMCP's init captures the bound subclass
method, so the registered JSON-RPC handler IS the translator. It catches
``BaseException``, narrows to ``ToolError`` whose ``__cause__`` is a
``pydantic.ValidationError``, then re-emits a friendly envelope — a dict
shaped ``{success, tool, error}`` for tools with ``structured_output=True``
(satisfied by ``aggregate_records``, ``search_records``, …) and a
``[TextContent(...)]`` content-block list otherwise. Runtime errors,
Odoo faults, and non-validation tool exceptions pass through untouched.
- ``normalize_domain_input`` now raises ``ValueError`` on garbage input.
Previously it silently returned ``[]`` and callers queried the wrong
record set with no error. The friendly message names both legal forms:
a JSON list ``[["is_timesheet", "=", true], ["project_id", "=", 37]]``
and the ``&``-prefixed Python literal that ``ast.literal_eval`` accepts.
``tests/test_tool_helpers.py`` gained three raise-path tests; two
pre-existing tests in ``tests/test_server.py`` were renamed
(``..._returns_empty_...`` → ``..._raises_...``) to reflect the new contract.
- Tightened ``aggregate_records`` description with a ``__count`` warning.
``__count`` is the row-count pseudo-column that Odoo's ``read_group`` /
``formatted_read_group`` returns on every row automatically; passing it
explicitly as a measure produces ``Invalid field '__count'``. The
description spells this out so agents stop guessing. Same tool gains
a ``list_instances`` hint that the other tools already carry.
- ``RuntimeWarning`` removed from the CLI test suite (root-cause fix).
``runpy.run_module("odoo_mcp.__main__", run_name="__main__")`` triggered
CPython's standard ``RuntimeWarning: 'odoo_mcp.__main__' found in
sys.modules after import of package 'odoo_mcp', but prior to execution``
whenever the test suite had already imported the package. Replaced with
a direct invocation of the entry-point expression ``sys.exit(cli.main())``
— same contract tested, no ``sys.modules`` pollution, no warning. No
``pytest.ini`` filter, no ``# noqa`` markers.
Dead-code cleanup
The first implementation tried ``@safe_tool_call`` decorators on the
function bodies. Verification proved that path can never reach a
``ValidationError`` (FastMCP validates parameters before calling the
function), so the decorator was always a no-op. Removed:
- ``safe_tool_call`` itself, plus its ``functools.wraps`` boilerplate and
five tests that exercised it. ``src/odoo_mcp/error_handling.py`` is
now reduced to its single reusable helper, ``_format_validation_error``
(bounded single-line renderer of ``pydantic.ValidationError``).
- Two ``@safe_tool_call`` decorators that were applied to
``aggregate_records`` and ``search_records`` in ``tools_read.py``.
- ``from .error_handling import safe_tool_call`` import (comment now
documents the move to the subclass layer).
Files touched
CHANGELOG.md | 35 ++ (1.3.2 entry)
pyproject.toml | 2 +- (1.3.1 -> 1.3.2)
server.json | 4 +- (1.3.1 -> 1.3.2)
src/odoo_mcp/error_handling.py | 63 ++ (NEW, then trimmed to helper only)
src/odoo_mcp/server_core.py | 78 ++ (NEW subclass _TranslationAwareFastMCP)
src/odoo_mcp/tool_helpers.py | 19 +- (normalize_domain_input raises)
src/odoo_mcp/tools_read.py | 25 ++ (description tweaks, decorator removed)
tests/test_cli.py | 33 +- (no more runpy)
tests/test_error_handling.py | 60 ++ (NEW translator + helper tests)
tests/test_server.py | 16 +- (2 tests renamed for raise contract)
tests/test_tool_helpers.py | 45 +- (raise-path coverage)
Public-API compatibility
- Same 41 tools, same parameter sets, same envelopes (the envelope the
failure path now produces is byte-identical to what a well-formed call
would produce — only the ``success`` flag differs).
- ``measure`` argument is unchanged; only its ``description`` string grew
by one warning. String change only, callers that pin ``"sum"|"avg"|...
via explicit ``measures=["amount_total:sum"]`` are unaffected.
- No new environment variables, no new dependencies, no new
configuration knobs.
Test evidence
$ python3 -m pytest -q
944 passed in 3.95s
$ uvx --from /home/ernst/code/mcp/mcp-odoo python - <<'PY' 2>&1 | tail
... real ToolError-with-ValidationError cause raises through the
... registered JSON-RPC dispatcher ...
type: dict
success: False
tool: 'aggregate_records'
error: 'Invalid input: measures: Value error, Input should be a valid list'
PY
Live MCP server (post-restart) confirms all four failure modes:
aggregate_records(measures="")
-> {"success": false, "tool": "aggregate_records",
"error": "Invalid input: measures: Input should be a valid list"}
search_records(domain="def x():")
-> {"success": false, "error": "Domain string is neither valid JSON
nor a Python literal: invalid syntax (...). Pass a list of
3-tuples, e.g. [\"is_timesheet\", \"=\", true], [\"project_id\",
\"=\", 37], or use the & prefix operator: ..."}
And valid input is unchanged:
aggregate_records(model="sale.order", group_by=["state"])
-> 4 rows: cancel=79, draft=17, sale=868, sent=64 (1028 orders)
Checklist
- [x] Version bumped (1.3.1 -> 1.3.2) per semver PATCH
- [x] server.json, pyproject.toml, CHANGELOG entry agree on 1.3.2
- [x] All 944 tests pass, no warnings
- [x] No public-API signature changes
- [x] No @safe_tool_call dead code, no warning filters, no # noqa
- [x] Live MCP server restart confirmed envelope shape end-to-end
…en check (v1.3.3) Regression: execute_approved_write rejected valid approval tokens with 'approval token does not match the canonical payload; re-run preview_write and validate_write' when caller-side record_ids arrived nested (e.g. [[3598, 3594, ...]]) instead of flat ([3598, 3594, ...]). Root cause: build_write_preview_report normalizes record_ids to a flat list[int] before hashing (agent_tools.py:154). verify_write_approval and write_approval_payload passed record_ids straight through from the approval dict, so any wrapper-induced nesting flipped the SHA-256 vs. the stored token. Same class of bug as the 1.2.1 _normalize_numbers int/float drift fix. Fix: - New _normalized_record_ids helper in agent_tools.py that flattens any nesting, casts digit-strings to int, drops non-digit entries (rather than coercing them to 0 -> Odoo super-user), and explicitly skips booleans (bool is an int subclass in Python). - verify_write_approval (agent_tools.py) and write_approval_payload (server_core.py) now route record_ids through the helper, matching the preview-side normalization. - 6 new regression tests in tests/test_agent_tools.py. No public API change. All 108 tests in test_agent_tools.py pass.
…envelope (v1.3.4) Regression: execute_approved_write error envelopes (token mismatch, validation record missing, payload mismatch, missing confirm, writes disabled, generic except) lacked the result key, which FastMCP's inferred outputSchema for tools with structured_output=True had marked as required from the success-path sample. The transport then rejected the envelope with 'Output validation error: result is a required property'. Fix: - New _normalize_write_response helper in tools_write.py that injects result: None on envelopes missing the key, no-op when present. - _execute_approved_write_gated routes all 7 return paths through it (5 early-return error gates + except arm + success path). - 4 new regression tests in tests/test_batch_write.py. No public API change. All 954 tests pass.
|
@tuanle96 two additional fixes fix(write-approval): normalize record_ids + inject result:None on every execute_approved_write envelope (v1.3.3 + v1.3.4)DescriptionUser-facing behavior change
Bug 1 —
|
|
@tuanle96 ready to be merged, all merge conflicts fixed |
| # build_write_preview_report so a transport- or wrapper-induced | ||
| # change in list structure (e.g. nested list) cannot change the | ||
| # SHA-256 approval token between preview/validate and execute. | ||
| "record_ids": _normalized_record_ids(approval.get("record_ids")), |
There was a problem hiding this comment.
[bug] Incomplete v1.3.3 fix (pair with execute path).
verify_write_approval and write_approval_payload now normalize nested record_ids via _normalized_record_ids, so nested shapes like [[3598, 3594]] pass token + payload equality. But _execute_approved_write_gated still does bare [int(x) for x in approval.get("record_ids") or []] (tools_write.py:547, outside this hunk) → TypeError on execute, caught as a generic envelope. The motivating "token mismatch on nested record_ids" bug is only half-fixed.
Suggestion: use _normalized_record_ids(...) (or the already-normalized stored payload ids) in the execute path, and add an e2e test: flat-built token + nested execute record_ids → successful Odoo call with flat ids.
| [[package]] | ||
| name = "odoo-mcp" | ||
| version = "1.1.0" | ||
| version = "1.3.0" |
There was a problem hiding this comment.
[bug] Version skew for the release train. pyproject.toml and server.json advertise 1.3.4, but uv.lock still pins the editable package as version = "1.3.0".
Suggestion: run uv lock so the editable odoo-mcp entry is 1.3.4.
|
|
||
| ## Auditing | ||
|
|
||
| Both tools emit one audit-log entry per call (when `ODOO_MCP_AUDIT_LOG` is |
There was a problem hiding this comment.
[suggestion] "Both tools emit one audit-log entry per call" is overstated. Only write_field_from_file calls record_write_event (preview + success). read_field_to_file never audits, so field extractions to disk leave no trail when ODOO_MCP_AUDIT_LOG is set.
Suggestion: audit the read path, or reword docs to say only the write tool is audited.
| | Symlink swap (TOCTOU) on write | File is opened with `O_RDONLY|O_NOFOLLOW`; size cap and SHA-256 derived from bytes read through that same fd. | | ||
| | File tamper between preview and execute | Execute re-reads the file and re-checks the SHA-256; mismatch → reject, no Odoo call. | | ||
| | Sensitive field leaking via filesystem | Field ACL redaction applies to `read_field_to_file`; redacted fields write a placeholder. | | ||
| | Token replay / payload swap | Execute verifies the approval token matches the current `content_sha256` of the current file content before doing anything. | |
There was a problem hiding this comment.
[suggestion] Threat-model row "Token replay / payload swap" overstates protection. Tokens are deterministic unkeyed SHA-256 of a caller-known payload. With ODOO_MCP_ENABLE_WRITES=1, the same approval can be re-executed indefinitely as long as file content is unchanged — no session store, TTL, or one-time consume (unlike execute_approved_write). Payload/file tamper is mitigated by re-hash; replay is not.
Suggestion: split the row, and align wording with chatter_post.
| - Field existence + ``readonly=False`` is revalidated against live | ||
| ``fields_get`` at execute time — preview-only checks would let an | ||
| attacker mutate the field on the server between the two calls. | ||
| - Same hard gates as ``execute_approved_write``: requires |
There was a problem hiding this comment.
[suggestion] Docstring claims "Same hard gates as execute_approved_write". Incomplete: execute_approved_write also requires a same-session validated approval record (TTL + one-time pop). An agent that can compute/reuse the deterministic token can execute without validate_write and can replay. Not worse than chatter_post, but the parity claim is inaccurate for operators comparing gates.
Suggestion: align wording with chatter_post, or route through the validation store.
| ) -> Any: | ||
| try: | ||
| return await super().call_tool(name, arguments) | ||
| except BaseException as exc: # noqa: BLE001 — last-line translator |
There was a problem hiding this comment.
[suggestion] Catching BaseException includes KeyboardInterrupt / SystemExit / CancelledError. If mis-detected as a validation ToolError (or racing cancellation), control-flow exceptions can be swallowed into an envelope.
Suggestion: catch Exception (or only ToolError), and re-raise non-validation BaseException subclasses immediately.
| a list), the server now returns the same `{"success": False, "tool": | ||
| …, "error": "Invalid input: measures: …"}` envelope the rest of the | ||
| error path uses, instead of leaking a raw `ValidationError` stack | ||
| trace. New `safe_tool_call` decorator in `error_handling.py` |
There was a problem hiding this comment.
[suggestion] 1.3.2 entry still documents a @safe_tool_call decorator "applied to aggregate_records and search_records". Shipped code removed that approach and uses _TranslationAwareFastMCP instead (error_handling.py explains why).
Suggestion: amend the 1.3.2 Fixed bullet to describe the FastMCP subclass hook.
| # content blocks; they get the same dict through TextContent. | ||
| return { | ||
| "success": False, | ||
| "tool": translated.text and json.loads(translated.text).get("tool", name), |
There was a problem hiding this comment.
[nit] Double json.loads(translated.text) for tool and error. Harmless but wasteful; also relies on truthiness of translated.text.
Suggestion: payload = json.loads(translated.text) once, then read keys.
| - Direct: ``approval`` and ``confirm=true`` together execute the write. | ||
|
|
||
| Security: | ||
| - Path must be absolute and inside ``file_root`` (or the first |
There was a problem hiding this comment.
[nit] Says path must sit inside file_root (or the first configured root). When file_root is omitted, runtime accepts a path under any configured root (restrict_field_file_path).
Suggestion: fix wording to match multi-root semantics.
|
Maintainer direction after the v1.3.0 MCP 2026-07-28 / SDK v2 release: this PR remains open, but it is not mergeable as-is. Please rebase on current If maintaining this branch is too costly, a fresh focused PR from current |
v1.3.0 — Field-File I/O + Cache-Bug-Fix
TL;DR
Two new MCP tools (
read_field_to_file,write_field_from_file) route long single-field payloads between Odoo and the local filesystem so LLMs no longer have to round-trip long HTML/source/rich-text content through the JSON-RPC envelope. Tool count 41 → 43. 932 tests pass.Why this release
LLMs routinely mistransform long HTML / source code / rich text that travels through the JSON-RPC envelope (double-escaped quotes, escaped newlines, embedded Unicode). Beyond the correctness pain, an 80 KB e-mail body, 50 KB product description or an Odoo Knowledge article (possibly even with embedded images) doubles in size when wrapped in JSON and lands in every subsequent tool call's history — a real context-window blow-up.
Field-file I/O solves both: the agent reads the field straight to disk, edits it locally, and writes it back. Only the SHA-256 + size fingerprint crosses the wire.
What's new
read_field_to_file(read domain) — stream one field's value to a local file. Text defaults to UTF-8; binary defaults to base64. Honors the field ACL (redacted values become[REDACTED by field ACL]placeholder, response flags it viafield_was_redacted). Symlink-safe, refuses overwrite, absolute-path-only,O_NOFOLLOWon the create fd.write_field_from_file(write domain) — two-phase preview/execute flow mirroringchatter_post. Preview returns a token carrying onlysha256:<hex>:<size>; execute re-reads the file, re-checks the hash (catches tamper + TOCTOU), fetches livefields_getmetadata (refusesreadonly=True), then routes throughconfirm=true+ODOO_MCP_ENABLE_WRITES=1. Binary round-trip via base64; otherwise UTF-8.ODOO_MCP_FIELD_FILE_ROOTSallow-list —os.pathsep-separated absolute directories. Hardened pattern fromODOO_MCP_ATTACHMENT_UPLOAD_ROOTS:Path.resolve()d before the containment check, so..traversal and symlink escapes cannot reach outside the operator's allow-list. Per-call override via thefile_rootargument on both tools.Fail-closed default with actionable error — with no env var and no per-call override, both tools reject every call. The error names the env var, lists platform-specific safe defaults (
~/.cache/odoo-mcp/field-fileson Linux/macOS,%LOCALAPPDATA%\odoo-mcp\Cache\field-fileson Windows), and explicitly warns against/tmp(typically world-readable — long field payloads would leak to other local users / processes).Threat model (summary)
/etcread_field_to_filerefuses overwrite (O_CREAT | O_EXCL);write_field_from_fileonly writes to OdooO_NOFOLLOWon create fd;Path.resolvecollapses symlinks before the containment checkO_RDONLY | O_NOFOLLOWon input fd; size cap and SHA-256 derived from that same fdread_field_to_file; redacted fields write a placeholdercontent_sha256before doing anythingBug fix (caught during operator testing)
write_field_from_fileexecute_kwargument shape. The initial implementation calledodoo.execute_method(model, "write", [[ids], {vals}]).OdooClientsplits*args, so Odoo receivedexecute_kw("project.task", "write", [[ids], {vals}])— a single positional arg. Odoo's XML-RPC dispatcher then unpacked it asProjectTask.write(*[[ids], {vals}])→write([ids], {vals}), and Odoo faulted withTypeError: ProjectTask.write() missing 1 required positional argument: 'vals'. Now called asodoo.execute_method(model, "write", [ids], {vals})so ids + vals arrive as two separate positional arguments (mirrorsexecute_approved_write, which always splatted*args).A regression test (
test_write_field_from_file_does_not_pack_args_into_single_list) plus an*args-awareexecute_methodtest stub (now matchingOdooClient's signature exactly) ensure a re-introduction of the nested-list form fires immediately. Real-world reproducer:project.taskdescriptionfield, record 134.Installation / upgrade
No migration needed. Pull, restart the MCP server.
For development workflows using
uvx --from <directory>(e.g. Cline), note that uv caches the source tree as an immutable archive in~/.cache/uv/archive-v0/. After pulling this release, runuv cache clean odoo-mcponce and restart the MCP server, or add--refreshto youruvxargs so uv auto-invalidates on every start (negligible overhead — the first build is ~3 sec and only runs when the source actually changed).Files changed
15 files, +1946 / -13.
CONTRIBUTING Checklist
pytest)ruff check— 0 issuesmypy— 0 issues across 34 source fileslint-imports— 2/2 contracts keptscripts/odoo_compose_smoke.py) — tool count 43 ✓Added+Security+Fixedsectionsdocs/field-file-io.mdoperator guidepyproject.tomlversion 1.2.1 → 1.3.0Related
project.task.description)ODOO_MCP_ATTACHMENT_UPLOAD_ROOTS(1.2.0) — same root-pattern, same fail-closed posture, same Field ACL integration