CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s
- server.py: `mcp.server.mcpserver.MCPServer`; StrictArgsFastMCP becomes StrictArgsMCPServer, whose call_tool takes and forwards v2's `context` and raises ToolError (the SDK logs anything else as an unexpected crash; the message reaches the caller either way). - stateless_http and transport_security moved from the constructor to `streamable_http_app(...)` in mount_mcp, with their reasons. - Per-request user identity is unchanged: the contextvar set around the ASGI call reaches the handler on both v2 paths (legacy stateless spawns from the request task; the 2026-07-28 modern path opens its task group inside the request). - pyproject: mcp[cli]>=2.2, no ceiling (installs are --locked). uv.lock regenerated with --upgrade-package mcp in the ci-python image: only mcp and its own dependencies moved (106 → 110 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
2.8 KiB
Python
77 lines
2.8 KiB
Python
"""Unknown tool arguments are rejected, never silently dropped (#2709).
|
|
|
|
The failure this pins: MCPServer 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: StrictArgsMCPServer 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 mcp.server.mcpserver.exceptions import ToolError
|
|
|
|
from scribe.mcp.server import StrictArgsMCPServer
|
|
|
|
|
|
def _echo_server() -> StrictArgsMCPServer:
|
|
mcp = StrictArgsMCPServer("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(ToolError) 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 MCPServer 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, StrictArgsMCPServer) # the guard is actually mounted
|
|
with pytest.raises(ToolError) 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
|