fix(mcp): annotate BM25 synthetic search tools for approval-gating clients - #34
Conversation
…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.
Scope addition: unrelated CI failure fixed hereCI went red on this PR with an Added Root cause
Verified against ruff's own output: The bug has been latent since that sort was written. It only surfaced now because a recent OpenAPI regeneration introduced Worth flagging separately: a CI blind spotThe Tests workflow last ran on So VerificationAll three CI steps pass locally on this branch: |
Summary
search_toolsandcall_toolwere shipping to clients with noToolAnnotations. Clients that gate approval on hints treat an unannotated tool as one that needs confirmation, so codex-cli auto-denies both in non-interactivecodex execruns and reportsuser cancelled MCP tool call.codex execaccounts_listreadOnlyHint: truedocs_searchreadOnlyHint: truesearch_toolscall_toolThe 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 noannotations=(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/codexcodex-rs/core/src/mcp_tool_call.rs:2160-2177:Default mode is
AppToolApproval::Auto(config/src/mcp_types.rs:26), which routes here. No annotations means the last line returnstrue, so approval is required, andcodex exechas no approver.Changes
src/late/mcp/server.py:_AnnotatedBM25SearchTransformsubclassesBM25SearchTransformand 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_copyinstead of rebuilding the tool: it keeps the upstream closure by reference (verified:c.fn is st.fnisTrue), so future upstream fixes to it are inherited. fastmcp 4.0 adds a catalog-membership security check insidecall_tool; rebuilding withTool.from_function(..., annotations=...)would silently drop it on upgrade.Both
transform_tools(base.py:202, servestools/list) andget_tool(base.py:209/211, servestools/call) dispatch throughself, so one subclass covers both paths. Verified on the live object.call_toolgets honest hints, on purposereadOnlyHint=False, destructiveHint=True, openWorldHint=True.Marking it read-only would unblock
codex execoutright in one line, and that was rejected:call_toolproxies 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_toolsnow runs without approval, butcall_toolstill 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 setdefault_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-upcall_read_toolthat server-side enforces the target carriesreadOnlyHint=Truecould 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
assert None is not None, then GREENruff checkclean on all changed filesto_mcp_tool():scripts/smoketest_streamable_http.pyhas been unrunnable since252e395removedsrc/late/mcp/routes.py, which it still imports at line 39. It fails identically on cleandevelop. 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
mcpobject; annotations are advisory metadata and the closure is preserved by identity, so execution is unchanged._PINNED_TOOLSpass through untouched. One cosmetic delta:to_mcp_tool()promotesannotations.titleto 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
late.mcp.routesimport).BaseSearchTransform, then delete this subclass.call_read_toolproxy described above.Crisp
https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_36c5a04d-27b7-49f9-a08d-46619bf1362b/