feat(mcp): port the server to MCP Python SDK v2 (FastMCP → MCPServer) (#2196)
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
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>
This commit is contained in:
+1
-1
@@ -321,7 +321,7 @@ def plain_rule_detail():
|
||||
|
||||
|
||||
class FakeMCP:
|
||||
"""Stand-in for the FastMCP server a tool module's ``register(mcp)`` is
|
||||
"""Stand-in for the MCPServer a tool module's ``register(mcp)`` is
|
||||
handed: records the ``name=`` of every ``@mcp.tool(...)`` registration in
|
||||
``names`` and leaves the function untouched, so a test can assert which
|
||||
tools a module exposes."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These exercise the ASGI dispatch + auth middleware by driving the app's ASGI
|
||||
callable directly. We avoid Quart's test_client() because it expects Quart's
|
||||
request pipeline to set `app._preserved_context` as a side effect, but our
|
||||
ASGI middleware forwards /mcp requests to FastMCP without touching the Quart
|
||||
ASGI middleware forwards /mcp requests to MCPServer without touching the Quart
|
||||
pipeline (correct production behavior), which causes test_client to fail on
|
||||
teardown.
|
||||
|
||||
@@ -85,12 +85,12 @@ async def test_mcp_endpoint_invalid_token_returns_401():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_endpoint_valid_token_passes_auth():
|
||||
"""With a valid Bearer, the request must successfully reach FastMCP's
|
||||
"""With a valid Bearer, the request must successfully reach MCPServer's
|
||||
initialize handler. Asserting `!= 401` is too weak: it lets a 404 from
|
||||
a path-mismatch (the original bug) through. FastMCP responds 200 to a
|
||||
a path-mismatch (the original bug) through. MCPServer responds 200 to a
|
||||
well-formed initialize handshake.
|
||||
|
||||
FastMCP's session manager normally starts via Quart's @before_serving
|
||||
MCPServer's session manager normally starts via Quart's @before_serving
|
||||
hook in production. This raw-ASGI test doesn't go through Quart's
|
||||
serving lifecycle, so we manually enter the session manager."""
|
||||
fake_key = MagicMock()
|
||||
@@ -113,7 +113,7 @@ async def test_mcp_endpoint_valid_token_passes_auth():
|
||||
status, _ = await _send_request(
|
||||
app, "POST", "/mcp",
|
||||
headers={
|
||||
# FastMCP's transport_security module enforces a Host
|
||||
# MCPServer's transport_security module enforces a Host
|
||||
# header (DNS-rebinding protection); without it the
|
||||
# request gets a 421 Misdirected Request.
|
||||
"Host": "testserver",
|
||||
@@ -123,7 +123,7 @@ async def test_mcp_endpoint_valid_token_passes_auth():
|
||||
},
|
||||
body=initialize_body,
|
||||
)
|
||||
assert status == 200, f"expected 200 from FastMCP initialize, got {status}"
|
||||
assert status == 200, f"expected 200 from MCPServer initialize, got {status}"
|
||||
|
||||
|
||||
# Note: there's no explicit "non-/mcp paths bypass the middleware" test here
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
"""Unknown tool arguments are rejected, never silently dropped (#2709).
|
||||
|
||||
The failure this pins: FastMCP validates tool arguments with a pydantic model
|
||||
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: StrictArgsFastMCP rejects any
|
||||
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 StrictArgsFastMCP
|
||||
from scribe.mcp.server import StrictArgsMCPServer
|
||||
|
||||
|
||||
def _echo_server() -> StrictArgsFastMCP:
|
||||
mcp = StrictArgsFastMCP("strict-test")
|
||||
def _echo_server() -> StrictArgsMCPServer:
|
||||
mcp = StrictArgsMCPServer("strict-test")
|
||||
|
||||
@mcp.tool()
|
||||
def echo(text: str = "") -> str:
|
||||
@@ -36,7 +37,7 @@ 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:
|
||||
with pytest.raises(ToolError) as exc:
|
||||
await mcp.call_tool("echo", {"txt": "hi"})
|
||||
msg = str(exc.value)
|
||||
assert "'txt'" in msg
|
||||
@@ -51,7 +52,7 @@ async def test_empty_arguments_pass():
|
||||
|
||||
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."""
|
||||
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"})
|
||||
@@ -65,8 +66,8 @@ async def test_the_original_regression_create_note_with_content():
|
||||
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:
|
||||
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"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user