Skip to content

feat: route long single-field payloads through the local filesystem - #55

Open
ERnsTL wants to merge 10 commits into
erpipe-org:mainfrom
ERnsTL:main
Open

feat: route long single-field payloads through the local filesystem#55
ERnsTL wants to merge 10 commits into
erpipe-org:mainfrom
ERnsTL:main

Conversation

@ERnsTL

@ERnsTL ERnsTL commented Jul 17, 2026

Copy link
Copy Markdown

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 via field_was_redacted). 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-listos.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).

Threat model (summary)

Threat Mitigation
Agent reads SSH keys or other secrets from /etc Absolute path + root containment; no root → every call rejected
Agent overwrites an unrelated file read_field_to_file refuses overwrite (O_CREAT | O_EXCL); write_field_from_file only writes to Odoo
Symlink swap (TOCTOU) on read O_NOFOLLOW on create fd; Path.resolve collapses symlinks before the containment check
Symlink swap (TOCTOU) on write O_RDONLY | O_NOFOLLOW on input fd; size cap and SHA-256 derived from 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 before doing anything

Bug fix (caught during operator testing)

write_field_from_file execute_kw argument shape. The initial implementation called odoo.execute_method(model, "write", [[ids], {vals}]). OdooClient splits *args, so Odoo received execute_kw("project.task", "write", [[ids], {vals}]) — a single positional arg. Odoo's XML-RPC dispatcher then unpacked it as ProjectTask.write(*[[ids], {vals}])write([ids], {vals}), and Odoo faulted with TypeError: ProjectTask.write() 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, which always splatted *args).

A regression test (test_write_field_from_file_does_not_pack_args_into_single_list) plus an *args-aware execute_method test stub (now matching OdooClient's signature exactly) ensure a re-introduction of the nested-list form fires immediately. Real-world reproducer: project.task description field, 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, run uv cache clean odoo-mcp once and restart the MCP server, or add --refresh to your uvx args 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

  • All 932 tests pass (pytest)
  • ruff check — 0 issues
  • mypy — 0 issues across 34 source files
  • lint-imports — 2/2 contracts kept
  • Smoke test (scripts/odoo_compose_smoke.py) — tool count 43 ✓
  • CHANGELOG 1.3.0 entry with Added + Security + Fixed sections
  • docs/field-file-io.md operator guide
  • pyproject.toml version 1.2.1 → 1.3.0
  • No breaking changes for existing tools

Related

…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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/odoo_mcp/server_core.py Outdated
against ``/tmp``.
"""
if file_root:
candidate = Path(file_root).expanduser().resolve(strict=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/odoo_mcp/tools_write.py Outdated
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 "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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."

Comment thread src/odoo_mcp/server_core.py Outdated
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

ERnsTL added 2 commits July 21, 2026 16:49
- 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.
@ERnsTL

ERnsTL commented Jul 21, 2026

Copy link
Copy Markdown
Author

@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)
@ERnsTL

ERnsTL commented Jul 21, 2026

Copy link
Copy Markdown
Author

Thanks @tuanle96 for the thorough review - all 6 comments addressed. Summary below; full details in the commit.

🔒 Security fix — file_root can no longer bypass the allow-list (bugs 1, 2, 3, server_core.py)

resolve_file_root_override used to accept any absolute file_root from the agent-controlled MCP tool arguments and never validate it against ODOO_MCP_FIELD_FILE_ROOTS. A prompt-injected agent could pass file_root="/" 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 - a bypass that worked even when the env allow-list was set.

Fix:

  • file_root is now a selector that must equal one of the configured roots after resolve. It cannot invent a new root.
  • ODOO_MCP_FIELD_FILE_ROOTS is always required - no free-form escape hatch.
  • The absolute-path check runs on expanduser()-ed path before resolve(), so file_root="scratch" is rejected instead of silently becoming $CWD/scratch.
  • When no file_root is supplied, restrict_field_file_path now validates the candidate against all configured roots and returns the matching root. Multi-root configs like /srv/a:/srv/b correctly accept paths under either root (the previous code only checked roots[0]).
  • Error message updated: removed the file_root= remediation step (it no longer applies), kept the per-platform safe defaults and the /tmp warning.
  • Docstring fixed: restrict_field_file_path now correctly points at _read_field_file (tools_write.py) and the O_CREAT|O_EXCL|O_NOFOLLOW block in read_field_to_file (tools_read.py).

🐛 Validation gap - encoding="base64" on non-binary fields (bug 4, tools_read.py)

The documented contract said "cannot force base64 on a non-binary type" but the code only did chosen_encoding = encoding.strip().lower() with no declared_binary guard. base64.b64decode(text, validate=False) would silently produce garbage bytes.

Fix:

  • After resolving chosen_encoding, if it's "base64" and not declared_binary, raise ValueError naming the field and the metadata-reported type so the agent knows what to fix.
  • Flipped validate=Falsevalidate=True on the decode path for binary fields as well - strict decode even on the supported case.

📝 Docs gap - field parameter description was backwards (bug 5, tools_write.py)

The description said "set encoding='base64' to decode a base64 blob from disk" but the execute path actually does base64.b64encode(re_read) - it ENCODES raw bytes for Odoo's wire format. Agents following the schema would double-encode.

Fix: rewrote the field and encoding parameter descriptions to match behavior. encoding='base64' means the file holds raw bytes that get base64-encoded for Odoo (default for binary fields); 'utf-8' means text decoded as a Unicode string. Also clarified the file_root description to call out the selector semantics.

🧪 Tests

  • Updated test_read_field_to_file_requires_configured_root_when_no_override - error message no longer mentions file_root= as a remediation.
  • Updated test_read_field_to_file_uses_explicit_file_root_override_as_selector (renamed) - now requires the env var to be set even when file_root is supplied.
  • Added 5 regression tests:
    • test_file_root_cannot_widen_allow_list - both / and /home/user rejected (read + write paths)
    • test_file_root_requires_env_var_even_when_supplied - file_root alone is not enough
    • test_relative_file_root_rejected_before_resolve - "scratch" rejected
    • test_multi_root_accepts_path_under_second_root - multi-root matching works
    • test_read_field_to_file_rejects_base64_on_non_binary_field - bug 4 guard fires

📚 Docs

  • docs/field-file-io.md: updated "No default root", "Enabling the tools", and "Per-call file_root selector" sections to reflect selector semantics (cannot widen allow-list); removed the file_root= step from the error example; added the prompt-injection rationale.

Verification:

  • pytest: 937 passed (was 932 - 5 new tests added)
  • ruff check: All checks passed
  • ⚠️ mypy: pre-existing pydantic_core stub-file error (positional-only parameters, Python 3.8+ vs older runtime) - unrelated to these changes

@ERnsTL

ERnsTL commented Jul 21, 2026

Copy link
Copy Markdown
Author

@tuanle96 New commit fixing validation errors when using certain tools. Applied the solution already used in many other tools.

Summary

Fixes Pydantic schema generation for list/dict tool parameters across 19 MCP tools. Parameters declared as Optional[List[…]], List[…], or Optional[Dict[…]] without an Annotated[…, Field(description=…)] wrapper were emitted into the JSON schema as a {"item": […]} object wrapper instead of a proper array. Clients (MCP inspector, Cline, Claude Desktop) then rejected the call with a list_type validation error. The fix wraps every affected parameter in Annotated[…, Field(description=…)] so Pydantic emits a clean {"type": "array", "items": …} (or {"type": "object"}) schema. Tools fixed: get_model_fields, search_records, read_record, aggregate_records, index_knowledge, data_quality_report, generate_json2_payload, inspect_model_relationships, analyze_upgrade_log, fit_gap_report, scan_addons_source, build_domain, preview_write, validate_write, chatter_post, search_across_instances, aggregate_across_instances.

Why

Before the fix, agents could not pass array arguments to several read, write, and diagnostics tools - every call returned a pydantic_core._pydantic_core.ValidationError: Input should be a valid list [type=list_type, input_value={'item': [...]}, input_type=dict]. The domain, fields, field_names, group_by, measures, checks, key_fields, args, kwargs, values, values_list, record_ids, context, partner_ids, attachment_ids, available_models, available_fields, installed_modules, conditions, addons_paths, and instances parameters were the worst-hit ones because they are the most-used list/dict arguments in the read and write surfaces.

Changes

  • src/odoo_mcp/tools_read.py - get_model_fields.field_names, search_records.fields, read_record.fields, aggregate_records.group_by / measures.
  • src/odoo_mcp/tools_knowledge.py - index_knowledge.fields.
  • src/odoo_mcp/tools_data_quality.py - data_quality_report.checks / key_fields.
  • src/odoo_mcp/tools_diagnostics.py - generate_json2_payload.args / kwargs, inspect_model_relationships.fields_metadata, analyze_upgrade_log.source_version / target_version, fit_gap_report.requirements / available_models / available_fields / installed_modules / business_context, scan_addons_source.addons_paths, build_domain.conditions / fields_metadata.
  • src/odoo_mcp/tools_write.py - preview_write.values / values_list / record_ids / context, validate_write.values / values_list / record_ids / context / fields_metadata, chatter_post.subtype_xmlid / partner_ids / attachment_ids / approval.
  • src/odoo_mcp/tools_cross_instance.py - search_across_instances.fields / instances, aggregate_across_instances.group_by / measures / instances.
  • pyproject.toml - version 1.3.01.3.1.
  • CHANGELOG.md - new [1.3.1] - 2026-07-21 entry.

No behavior change at runtime - only the JSON-Schema surface for tools/list is corrected. Existing tests and call sites are unchanged.

Environment / Dependency Note

The fix relies on the mcp>=1.27,<2 constraint that was already declared in pyproject.toml. Environments that still ship mcp 1.12.x (or earlier) will fail to import this server because the mcp.resource(annotations=…) and mcp.tool(structured_output=True) features used in server_core.py and the tool surface require mcp>=1.27. After upgrading to mcp>=1.27,<2 (we verified against mcp 1.28.1), the import succeeds and all 932 tests pass.

Verification

  • All 6 modified files pass ast.parse cleanly.
  • Full test suite: 932 passed, 1 warning (the warning is pre-existing and unrelated to this change).
  • Direct inputSchema inspection of the running server confirms search_records.fields, search_records.domain, aggregate_records.group_by, and friends now export as proper JSON arrays ({"type": "array", "items": {"type": "string"}}) and objects ({"type": "object"}) instead of the old {"item": ...} wrapper.
  • Live re-verification: the 4 calls that previously failed in the live session now execute end-to-end against the running server for any client that follows the JSON-Schema surface (e.g. search_records(model="account.analytic.line", domain=[["task_id","=",7493]], limit=3) returned 3 records, project 37 confirmed).

@ERnsTL
ERnsTL requested a review from tuanle96 July 21, 2026 15:49
@ERnsTL

ERnsTL commented Jul 21, 2026

Copy link
Copy Markdown
Author

@tuanle96 security comments fixed, fixed several tools (datastructure wrapping issue) and resolved merge conflicts.
Please re-review.

@ERnsTL

ERnsTL commented Jul 21, 2026

Copy link
Copy Markdown
Author

New fix release.

Summary

Patch release 1.3.1 → 1.3.2 for odoo-mcp that closes the four most common agent-side validation gaps. Pydantic ValidationErrors on tool-call arguments no longer leak raw multi-line stack traces: they are translated into the same {"success": false, "tool": …, "error": "Invalid input: …"} envelope the rest of the tool surface uses, with the field name spelled out so the agent can retry without reading a stack. Malformed domains stop silently matching the wrong record set; they raise ValueError with a message that names both legal forms (JSON list and &-prefixed Python literal). The __count pseudo-column warning is added to the aggregate_records description. A redundant CPython runpy RuntimeWarning is fixed at its root cause. No public-API signature change; 944 tests pass; 0 warnings.

What changed

  • Friendly envelope on Pydantic argument validation (root-cause fix). FastMCP validates annotated tool parameters via Pydantic before the tool body runs and converts any ValidationError to a mcp.server.fastmcp.exceptions.ToolError whose __cause__ is the original error. Trying to wrap the function body with a decorator bypassed the dispatcher entirely; patching mcp.call_tool post-init bypassed it because FastMCP's __init__ captures the bound method by reference via self._mcp_server.call_tool(validate_input=False)(self.call_tool). Patching mcp._tool_manager.call_tool bypassed it because the JSON-RPC dispatcher doesn't go through that path. The fix is a small _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 handler IS the translator. It catches BaseException, narrows to ToolError whose __cause__ is a pydantic.ValidationError, then re-emits the friendly envelope — a dict shaped {success, tool, error} for tools with structured_output=True (satisfied by aggregate_records, search_records, and most read tools) and a [TextContent(...)] content-block list otherwise. Runtime errors, Odoo faults, and non-validation tool exceptions pass through untouched, so the existing error semantics for execute_method and write-gate paths are unchanged.

  • normalize_domain_input now raises ValueError on garbage input. The previous contract 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 new raise-path tests; tests/test_server.py has two pre-existing tests renamed (..._returns_empty_......_raises_...) to reflect the new contract.

  • __count warning + list_instances hint on aggregate_records and search_records descriptions. __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 it has to be requested. Same tools gain a list_instances hint that 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 (which every other test does). 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 because FastMCP validates parameters before calling the function, so the decorator was always a no-op. Removed the decorator, its functools.wraps boilerplate, the two application sites on aggregate_records and search_records, the from .error_handling import safe_tool_call import, and the five tests that exercised it. error_handling.py is now reduced to its single reusable helper, _format_validation_error (bounded single-line renderer of pydantic.ValidationError).

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 |  41 ++    (helper only, NEW)
src/odoo_mcp/server_core.py    |  78 ++    (_TranslationAwareFastMCP subclass, NEW)
src/odoo_mcp/tool_helpers.py   |  19 +-    (normalize_domain_input raises)
src/odoo_mcp/tools_read.py     |  19 ++    (description tweaks, decorator removed)
tests/test_cli.py              |  41 +-    (no more runpy; direct sys.exit)
tests/test_error_handling.py   | 121 ++    (translator + helper tests, NEW)
tests/test_server.py           |  24 +-    (2 tests renamed for raise contract)
tests/test_tool_helpers.py     |  53 +-    (3 raise-path coverage tests added)

11 files, 401 +/36 -.

Compatibility

  • Public API: zero changes. Same 41 tools, same parameter sets, same envelope shape. The failure-path envelope is byte-identical to a well-formed call's envelope - only the success flag differs.
  • measure argument unchanged; only its description string grew. 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

End-to-end smoke through the deployed uvx --from /home/xxx/code/mcp/mcp-odoo --refresh build, post-restart:

aggregate_records(model="sale.order", group_by=["state"], measures="")
-> {"success": false, "tool": "aggregate_records",
    "error": "Invalid input: measures: Input should be a valid list"}

search_records(model="sale.order", domain="def x():", limit=1)
-> {"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: ..."}

aggregate_records(model="sale.order", group_by=["state"])  # valid
-> {"success": true, "method": "read_group", "major_version": 16,
    "row_count": 4, "rows": [{"state": "cancel", "__count": 79, ...},
                               {"state": "draft", "__count": 17, ...},
                               {"state": "sale",   "__count": 868, ...},
                               {"state": "sent",   "__count": 64, ...}]}

Checklist

  • Version bumped (1.3.1 → 1.3.2, PATCH per semver)
  • server.json, pyproject.toml, and CHANGELOG.md agree on 1.3.2
  • All 944 tests pass, no warnings
  • No public-API signature changes
  • No @safe_tool_call dead code, no warning filters, no # noqa
  • Live MCP server restart confirmed envelope shape end-to-end

ERnsTL added 3 commits July 21, 2026 19:15
…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.
@ERnsTL

ERnsTL commented Jul 26, 2026

Copy link
Copy Markdown
Author

@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)

Description

User-facing behavior change

preview_writevalidate_writeexecute_approved_write no longer fails with two regressions that surfaced during a real Odoo spam-partner cleanup run against a live summit-solutions.odoo.com instance.

Bug 1 — approval token does not match the canonical payload on execute_approved_write

Symptom: execute_approved_write rejects the approval with "approval token does not match the canonical payload; re-run preview_write and validate_write" even though the caller passed back the exact approval returned by preview_write + validate_write. Triggers reliably when record_ids arrives nested (e.g. [[3598, 3594, ...]] instead of [3598, 3594, ...]) — observed when issuing a 30-record unlink against res.partner for a spam-cleanup job (11 calendar-attendees in the same batch succeeded, 30 res.partner records in the next batch failed with the same token).

Root cause (src/odoo_mcp/agent_tools.py:262-276 + src/odoo_mcp/server_core.py:314-326): 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 then passed record_ids straight through from the approval dict, so any transport- or framework-induced shape change between preview/validate and execute flipped the SHA-256 vs. the stored token. Same class of bug as the _normalize_numbers int/float drift fix shipped in 1.2.1 (#48).

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 = the Odoo super-user), and explicitly skips bool (an int subclass in Python — never accidentally accept True/False as a record id). verify_write_approval and write_approval_payload now route record_ids through this helper.

Bug 2 — Output validation error: 'result' is a required property on the error path

Symptom: When execute_approved_write errors out (token mismatch, validation record missing, payload mismatch, confirm missing, writes disabled, generic exception), the FastMCP transport rejects the envelope with "Output validation error: 'result' is a required property" instead of returning the error to the caller.

Root cause (src/odoo_mcp/tools_write.py:_execute_approved_write_gated): the success return carried result: <bool> from the Odoo call, but the five early-return error gates and the except Exception arm did not set result. FastMCP's inferred outputSchema for tools with structured_output=True had promoted the success-path result to required. When the framework then validated an error envelope, it complained about the missing key — even though the error itself was correct.

Fix: new _normalize_write_response helper in tools_write.py that injects result: None on envelopes missing the key, no-op when the key is already present. _execute_approved_write_gated now routes all 7 return paths through it (5 early-return error gates + except arm + success path). No change to success / error semantics — result: null simply signals "no Odoo return value because we never reached the execute call".

Reproduction (before fix)

# Bug 1 — with caller-side wrapped record_ids:
from odoo_mcp.agent_tools import build_approval_token, verify_write_approval
payload = {
    "model": "res.partner", "operation": "unlink",
    "record_ids": [3598, 3594], "values": {}, "context": {},
    "instance": "default",
}
token = build_approval_token(payload)
is_valid, _ = verify_write_approval(
    {**payload, "record_ids": [[3598, 3594]], "token": token}
)
assert is_valid is False  # bug: token mismatch on nested payload

# Bug 2 — execute error envelope missing result key:
from odoo_mcp.tools_write import _execute_approved_write_gated, _normalize_write_response
# (FakeAppContext with empty write_approvals forces the token-mismatch gate)
out = _execute_approved_write_gated(ctx, bogus_approval, confirm=True)
assert "result" in out  # bug: AssertionError

Reproduction (after fix)

# Both pass cleanly.

Files changed

 CHANGELOG.md                |  69 ++++++++++++++++++++
 pyproject.toml              |   2 +-
 src/odoo_mcp/agent_tools.py |  35 ++++++++++++-
 src/odoo_mcp/server_core.py |   9 +++-
 src/odoo_mcp/tools_write.py |  48 ++++++++++++++------
 tests/test_agent_tools.py   | 108 ++++++++++++++++++++++++++++++++++++++++++++
 tests/test_batch_write.py   |  81 +++++++++++++++++++++++++++++++++
 7 files changed, 335 insertions(+), 17 deletions(-)

Test coverage

  • 6 new regression tests in tests/test_agent_tools.py:

    • _normalized_record_ids flattens nested / triple-nested / mixed-type input, drops non-digit entries, skips booleans.
    • verify_write_approval accepts nested, triple-nested, and digit-string record_ids against a flat-built token (the original bug shape).
    • write_approval_payload normalizes nested input to a flat list[int] for the payload-equality check in _execute_approved_write_gated.
  • 4 new regression tests in tests/test_batch_write.py:

    • _normalize_write_response injects result: None on error envelopes, is a no-op when result is already present, is defensive against non-dict input.
    • End-to-end: a token-mismatch execute call returns {"success": false, "result": null, "error": ...}.

Quality gates

$ uv run python -m pytest tests/test_agent_tools.py \
                     tests/test_batch_write.py \
                     tests/test_write_policy.py
166 passed in 0.74s

$ uv run python -m pytest tests/
954 passed in 4.00s    # (was 946 in 1.3.2; +10 new, 0 broken)

Compatibility

  • No public API change. Approval tokens for single-record writes stay byte-identical to 1.3.2 (the helper flattens + int-casts without altering the canonical shape the preview already produces).
  • No new env vars.
  • No behavior change for clients that pass record_ids as a flat list — the helper is a no-op for them.
  • No behavior change for the success path of execute_approved_writeresult was already populated.

Related

  • _normalize_numbers int/float drift fix from 1.2.1 (#48) — same class of "transport-shape-drift causes approval-token mismatch" bug. This PR applies the same hardening to record_ids (and adds _normalize_write_response for the complementary envelope bug).

@ERnsTL

ERnsTL commented Jul 26, 2026

Copy link
Copy Markdown
Author

@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")),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread uv.lock
[[package]]
name = "odoo-mcp"
version = "1.1.0"
version = "1.3.0"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread docs/field-file-io.md

## Auditing

Both tools emit one audit-log entry per call (when `ODOO_MCP_AUDIT_LOG` is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread docs/field-file-io.md
| 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. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread CHANGELOG.md
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`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@tuanle96

Copy link
Copy Markdown
Collaborator

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 main and reduce the branch to the field-file I/O feature plus its focused tests/docs. Drop the bundled version/release-train and FastMCP-era changes, resolve the outstanding review findings, regenerate uv.lock, and rerun the current Python 3.10/3.11/3.12 + Odoo 19 Inspector matrix.

If maintaining this branch is too costly, a fresh focused PR from current main is preferable; contributor credit will be preserved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants