Skip to content

fix(mcp): annotate BM25 synthetic search tools for approval-gating clients - #34

Merged
Zernio-Elean merged 2 commits into
developfrom
fix/annotate-search-transform-tools
Aug 4, 2026
Merged

fix(mcp): annotate BM25 synthetic search tools for approval-gating clients#34
Zernio-Elean merged 2 commits into
developfrom
fix/annotate-search-transform-tools

Conversation

@Zernio-Elean

Copy link
Copy Markdown
Contributor

Summary

  • search_tools and call_tool were shipping to clients with no ToolAnnotations. Clients that gate approval on hints treat an unannotated tool as one that needs confirmation, so codex-cli auto-denies both in non-interactive codex exec runs and reports user cancelled MCP tool call.
  • Customer-reported and reproduced. The failure split cleanly along annotation lines, 4 out of 4:
Tool annotations Result in codex exec
accounts_list readOnlyHint: true completed
docs_search readOnlyHint: true completed
search_tools none cancelled
call_tool none cancelled

The pinned tools were fine because tool_def() already annotates them. Only the two synthetic meta-tools built by fastmcp were bare.

Root cause

fastmcp builds both synthetic tools with Tool.from_function(...) and no annotations= (transforms/search/base.py:244, bm25.py:129). Still true in 3.4.5 and 4.0.0b1, so upgrading does not fix it.

Codex side, openai/codex codex-rs/core/src/mcp_tool_call.rs:2160-2177:

if destructive_hint == Some(true) { return true; }
let read_only_hint = annotations.and_then(|a| a.read_only_hint).unwrap_or(false);
if read_only_hint { return false; }
destructive_hint.unwrap_or(true) || open_world_hint.unwrap_or(true)

Default mode is AppToolApproval::Auto (config/src/mcp_types.rs:26), which routes here. No annotations means the last line returns true, so approval is required, and codex exec has no approver.

Changes

  • src/late/mcp/server.py: _AnnotatedBM25SearchTransform subclasses BM25SearchTransform and overrides _make_search_tool / _make_call_tool.
  • tests/test_search_transform_annotations.py: new.
  • scripts/smoketest_streamable_http.py: wire-level assertions (see caveat below).

Why model_copy instead of rebuilding the tool: it keeps the upstream closure by reference (verified: c.fn is st.fn is True), so future upstream fixes to it are inherited. fastmcp 4.0 adds a catalog-membership security check inside call_tool; rebuilding with Tool.from_function(..., annotations=...) would silently drop it on upgrade.

Both transform_tools (base.py:202, serves tools/list) and get_tool (base.py:209/211, serves tools/call) dispatch through self, so one subclass covers both paths. Verified on the live object.

call_tool gets honest hints, on purpose

readOnlyHint=False, destructiveHint=True, openWorldHint=True.

Marking it read-only would unblock codex exec outright in one line, and that was rejected: call_tool proxies to any hidden tool, so a read-only hint would bypass client approval for every destructive write reachable through it, in every client. That trades a UX outage for silent unapproved writes.

Consequence, stated plainly: this PR does not fully unblock codex exec. search_tools now runs without approval, but call_tool still requires it and is still auto-denied non-interactively, so the ~430 hidden tools stay unreachable there. That is codex working as designed. Affected users set default_tools_approval_mode = "approve" in ~/.codex/config.toml, which is explicit consent in the right place.

MCP annotations are static per tool in tools/list, so there is no way to vary them per invocation through a single proxy. A follow-up call_read_tool that server-side enforces the target carries readOnlyHint=True could truthfully self-annotate read-only and restore the read half non-interactively. Deliberately out of scope here.

Also closes a compliance gap: the Anthropic Connectors Directory requires every tool to carry readOnly/destructive hints (server.py:128-131), and these two were the only ones missing them.

Testing

  • TDD: RED verified first, both tests failed on assert None is not None, then GREEN
  • Full suite: 178 passed, 14 skipped
  • ruff check clean on all changed files
  • Wire-level check via to_mcp_tool():
    search_tools   readOnly=True  destructive=False openWorld=False
    call_tool      readOnly=False destructive=True  openWorld=True
    get_tool path: True
    
  • Smoketest assertions are dormant and unexecuted. scripts/smoketest_streamable_http.py has been unrunnable since 252e395 removed src/late/mcp/routes.py, which it still imports at line 39. It fails identically on clean develop. The added assertions are correct but cannot run until the script is repaired, which is filed separately rather than done here as a drive-by.

Blast radius

None. STDIO (Claude Desktop) and ChatGPT share the same mcp object; annotations are advisory metadata and the closure is preserved by identity, so execution is unchanged. _PINNED_TOOLS pass through untouched. One cosmetic delta: to_mcp_tool() promotes annotations.title to the wire-level tool title, so clients now show "Search available tools" / "Call a tool discovered via search" instead of the raw names.

Follow-ups

  1. Repair the dead smoketest (late.mcp.routes import).
  2. Upstream PR to fastmcp adding an annotations knob to BaseSearchTransform, then delete this subclass.
  3. Evaluate the call_read_tool proxy described above.

Crisp

https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_36c5a04d-27b7-49f9-a08d-46619bf1362b/

…ients

fastmcp builds search_tools/call_tool without ToolAnnotations. Clients that
gate approval on hints treat an unannotated tool as needing confirmation, so
codex-cli auto-denies both in non-interactive `codex exec` runs and reports
"user cancelled MCP tool call". Pinned tools were unaffected because tool_def()
already annotates them, which is why accounts_list and docs_search kept working
while the two meta-tools failed.

Subclass BM25SearchTransform and wrap the parent's Tool via model_copy rather
than rebuilding it, so the upstream closure is kept by reference and future
upstream fixes to it are inherited (4.0 adds a catalog-membership check inside
call_tool).

call_tool keeps honest hints (readOnly=False, destructive=True, openWorld=True).
Marking it read-only would unblock codex exec outright but would bypass client
approval for every write reachable through the proxy. codex exec therefore still
denies call_tool itself; only search_tools and the pinned tools are unblocked.

Also closes a compliance gap: the Anthropic Connectors Directory requires every
tool to carry readOnly/destructive hints, and these two were the only ones
missing them.

The smoketest assertions are dormant: the script has been unrunnable since
252e395 removed late.mcp.routes. Repairing it is filed separately.
…f isort

generate_resources.py sorted the ..resources import block with a plain
sorted(), which is case-sensitive, while ruff's isort defaults to
case-insensitive (isort case-sensitive is false and pyproject sets no
override). The comment already claimed the sort matched ruff; it did not.

Latent since the sort was written. It only surfaced when a regeneration
added AdsResource and ConnectedAppsResource, the first names where the two
orderings disagree: AdsResource precedes AdTargetingResource case-insensitively
but follows it case-sensitively.

CI did not catch it because the regeneration bot pushes with GITHUB_TOKEN,
which by design does not trigger workflows, so Tests last ran on develop on
2026-07-31.

Regenerates the two affected import lines so the committed file matches what
the generator now emits.
@Zernio-Elean

Copy link
Copy Markdown
Contributor Author

Scope addition: unrelated CI failure fixed here

CI went red on this PR with an I001 isort error in src/late/client/late_client.py. It is not caused by this change. Verified by running ruff against the develop version of that file in isolation, with none of this branch's changes applied: same error, byte-identical file.

Added 97601bd to unblock. Two lines of generated output plus a one-line generator fix.

Root cause

scripts/generate_resources.py:506 built the ..resources import block with a plain sorted(). That is case-sensitive; ruff's isort is case-insensitive by default (case-sensitive defaults to false, and [tool.ruff.lint.isort] sets no override). The comment directly above it already claimed the sort matched ruff isort, which was wrong.

Verified against ruff's own output:

ruff order == sorted(key=str.lower): True
ruff order == sorted() plain      : False

The bug has been latent since that sort was written. It only surfaced now because a recent OpenAPI regeneration introduced AdsResource and ConnectedAppsResource, the first class names where the two orderings actually disagree: AdsResource sorts before AdTargetingResource case-insensitively and after it case-sensitively.

Worth flagging separately: a CI blind spot

The Tests workflow last ran on develop on 2026-07-31. Every commit since is a chore: regenerate from OpenAPI spec pushed by the regeneration bot using GITHUB_TOKEN, and GitHub by design does not trigger workflows on pushes made with that token.

So develop has been failing lint for days with nothing reporting it, and this PR is simply the first thing to run the workflow since. That is a bigger problem than the sort bug and is not addressed here. Worth its own issue: either have the regeneration workflow run lint/tests before pushing, or push with a PAT / workflow_dispatch so the checks actually fire.

Verification

All three CI steps pass locally on this branch:

ruff check src tests      -> All checks passed!
mypy src                  -> Success: no issues found in 114 source files
pytest                    -> 178 passed, 14 skipped

@Zernio-Elean
Zernio-Elean merged commit f6283e4 into develop Aug 4, 2026
4 checks passed
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.

1 participant