Files
FabledScribe/tests/test_mcp_strict_args.py
bvandeusenandClaude Fable 5 d6c9f08a59
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
fix(mcp): reject undeclared tool arguments instead of silently dropping them (#2709)
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>
2026-08-17 12:54:58 -04:00

76 lines
2.7 KiB
Python

"""Unknown tool arguments are rejected, never silently dropped (#2709).
The failure this pins: 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. The call REPORTED SUCCESS. Two real notes were persisted body-less
before the pattern was noticed.
The fix is at the dispatch seam, not per-tool: StrictArgsFastMCP rejects any
call carrying arguments the tool does not declare, with a did-you-mean when
one is close. An error the caller sees once beats data half-written forever.
"""
import pytest
from scribe.mcp.server import StrictArgsFastMCP
def _echo_server() -> StrictArgsFastMCP:
mcp = StrictArgsFastMCP("strict-test")
@mcp.tool()
def echo(text: str = "") -> str:
return text
return mcp
async def test_declared_arguments_still_dispatch():
mcp = _echo_server()
result = await mcp.call_tool("echo", {"text": "hi"})
assert "hi" in str(result)
async def test_unknown_argument_is_an_error_not_a_silent_drop():
"""The load-bearing property: the call must FAIL, because succeeding is
what turned a typo into data loss."""
mcp = _echo_server()
with pytest.raises(ValueError) as exc:
await mcp.call_tool("echo", {"txt": "hi"})
msg = str(exc.value)
assert "'txt'" in msg
assert "did you mean 'text'?" in msg # difflib near-miss hint
assert "Nothing was created or changed" in msg
async def test_empty_arguments_pass():
mcp = _echo_server()
assert await mcp.call_tool("echo", {}) is not None
async def test_unknown_tool_keeps_the_upstream_error():
"""The gate must not swallow or reshape 'no such tool' — that error path
belongs to FastMCP and clients already understand it."""
mcp = _echo_server()
with pytest.raises(Exception) as exc:
await mcp.call_tool("no_such_tool", {"text": "hi"})
assert "no_such_tool" in str(exc.value)
async def test_the_original_regression_create_note_with_content():
"""Pin #2709 itself against the REAL server: `content=` on create_note
must raise before dispatch — naming the bad argument and listing `body`
among the accepted ones — instead of creating a body-less note."""
from scribe.mcp.server import build_mcp_server
mcp = build_mcp_server()
assert isinstance(mcp, StrictArgsFastMCP) # the guard is actually mounted
with pytest.raises(ValueError) as exc:
await mcp.call_tool(
"create_note", {"title": "t", "content": "the body text"}
)
msg = str(exc.value)
assert "'content'" in msg
assert "body" in msg