fix(mcp): reject undeclared tool arguments instead of silently dropping them (#2709)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 37s

FastMCP validates tool arguments with a pydantic model whose extra-field
policy is 'ignore', so create_note(content=...) — a plausible near-miss
for body=, primed by add_task_log's content — ran successfully, stored
body: '', and left a record embedding/search cannot see. Two real notes
were persisted body-less before the pattern was noticed; create_task
only 'worked' because those calls happened to use the right name.

StrictArgsFastMCP rejects any tool call carrying arguments the tool does
not declare, before dispatch, with a did-you-mean hint when one is close
and the declared list when none is. Applied at the dispatch seam so
every tool gets the guarantee — an error the caller sees once beats data
half-written forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:54:58 -04:00
co-authored by Claude Fable 5
parent 3162332a13
commit d6c9f08a59
2 changed files with 118 additions and 1 deletions
+43 -1
View File
@@ -1,6 +1,8 @@
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
from __future__ import annotations
import difflib
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from quart import Quart
@@ -165,6 +167,46 @@ def _body_calls_write_tool(body: bytes) -> bool:
return False
class StrictArgsFastMCP(FastMCP):
"""A FastMCP that REJECTS tool calls carrying undeclared arguments.
FastMCP validates arguments with a pydantic model built from the tool
signature, and pydantic's default extra-field policy is "ignore" — so a
misnamed argument simply vanishes and the tool runs with that field's
default. On the create/update tools the default is "", which turns a
plausible near-miss (`content=` for `body=`, primed by add_task_log's
`content`) into SILENT DATA LOSS: the call reports success and stores an
empty body, leaving a record search cannot see (#2709). Two notes were
persisted body-less that way before anyone noticed.
An error the caller sees once is strictly better than data half-written
forever, so the policy is applied to every tool, not just the two that
bit: nothing here knows tool semantics, only that an argument nobody
declared cannot have been meant to be dropped.
"""
async def call_tool(self, name, arguments):
try:
tool = self._tool_manager.get_tool(name)
except Exception:
tool = None # unknown tool → let upstream produce its own error
if tool is not None:
declared = set((tool.parameters or {}).get("properties", {}))
unknown = sorted(set(arguments or {}) - declared)
if unknown:
hints = []
for arg in unknown:
close = difflib.get_close_matches(arg, sorted(declared), n=1)
suggestion = f" (did you mean '{close[0]}'?)" if close else ""
hints.append(f"'{arg}'{suggestion}")
raise ValueError(
f"{name} does not accept argument(s) {', '.join(hints)}. "
f"It accepts: {', '.join(sorted(declared))}. Nothing was "
"created or changed — retry with the declared names."
)
return await super().call_tool(name, arguments)
def build_mcp_server() -> FastMCP:
"""Build the FastMCP instance with all tools registered.
@@ -185,7 +227,7 @@ def build_mcp_server() -> FastMCP:
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway.
mcp = FastMCP(
mcp = StrictArgsFastMCP(
"scribe",
instructions=_INSTRUCTIONS.strip(),
stateless_http=True,