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,7 +1,7 @@
|
||||
"""Per-request MCP context.
|
||||
|
||||
The ASGI middleware (mcp/server.py) populates `_user_id_ctx` from
|
||||
scope['scribe_user_id'] before dispatching to FastMCP tool handlers.
|
||||
scope['scribe_user_id'] before dispatching to MCPServer tool handlers.
|
||||
Tool functions then call `current_user_id()` to retrieve it.
|
||||
|
||||
Tools must never fall back to a default user — `current_user_id()` raises
|
||||
|
||||
+49
-42
@@ -1,9 +1,10 @@
|
||||
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
||||
"""MCPServer 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.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from quart import Quart
|
||||
|
||||
@@ -256,10 +257,10 @@ def _body_calls_write_tool(body: bytes) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class StrictArgsFastMCP(FastMCP):
|
||||
"""A FastMCP that REJECTS tool calls carrying undeclared arguments.
|
||||
class StrictArgsMCPServer(MCPServer):
|
||||
"""An MCPServer that REJECTS tool calls carrying undeclared arguments.
|
||||
|
||||
FastMCP validates arguments with a pydantic model built from the tool
|
||||
The SDK 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
|
||||
@@ -274,7 +275,10 @@ class StrictArgsFastMCP(FastMCP):
|
||||
declared cannot have been meant to be dropped.
|
||||
"""
|
||||
|
||||
async def call_tool(self, name, arguments):
|
||||
# ToolError, not ValueError: the SDK returns either one's text to the
|
||||
# caller as an error result, but logs anything that is not a ToolError as
|
||||
# an unexpected crash. A misnamed argument is the caller's mistake.
|
||||
async def call_tool(self, name, arguments, context=None):
|
||||
try:
|
||||
tool = self._tool_manager.get_tool(name)
|
||||
except Exception:
|
||||
@@ -288,56 +292,35 @@ class StrictArgsFastMCP(FastMCP):
|
||||
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(
|
||||
raise ToolError(
|
||||
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)
|
||||
return await super().call_tool(name, arguments, context)
|
||||
|
||||
|
||||
def build_mcp_server() -> FastMCP:
|
||||
"""Build the FastMCP instance with all tools registered.
|
||||
def build_mcp_server() -> MCPServer:
|
||||
"""Build the MCPServer instance with all tools registered.
|
||||
|
||||
DNS-rebinding protection is disabled: FastMCP's default allow-list is
|
||||
just localhost variants, which means any deployment behind a reverse
|
||||
proxy (Traefik with a hostname like devassistant.traefik.internal,
|
||||
Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
|
||||
model that protection addresses — a malicious browser page rebinding
|
||||
DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
|
||||
behind a reverse proxy with bearer-token auth as the real security
|
||||
boundary.
|
||||
Transport settings (statelessness, DNS-rebinding protection) are not
|
||||
here: since SDK v2 they belong to the ASGI app, built in `mount_mcp`.
|
||||
"""
|
||||
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
|
||||
# The stateful default strands Claude Code after a container redeploy —
|
||||
# it reconnects with the now-unknown session id, the server returns 404,
|
||||
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
|
||||
# so the connection stays dead until a manual /mcp retry. Stateless makes
|
||||
# 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 = StrictArgsFastMCP(
|
||||
"scribe",
|
||||
instructions=_INSTRUCTIONS.strip(),
|
||||
stateless_http=True,
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False,
|
||||
),
|
||||
)
|
||||
mcp = StrictArgsMCPServer("scribe", instructions=_INSTRUCTIONS.strip())
|
||||
from scribe.mcp.tools import register_all
|
||||
register_all(mcp)
|
||||
return mcp
|
||||
|
||||
|
||||
def mount_mcp(app: Quart) -> None:
|
||||
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
||||
"""Mount the MCPServer streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
||||
|
||||
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
||||
A small ASGI middleware between Quart and the MCPServer sub-app validates the
|
||||
Bearer token against the api_keys table. Authenticated requests have their
|
||||
user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
|
||||
to read.
|
||||
|
||||
FastMCP's streamable_http session manager owns a task group that must be
|
||||
The SDK's streamable_http session manager owns a task group that must be
|
||||
running before it can serve requests. In a stand-alone Starlette deployment
|
||||
that would happen via the Starlette `lifespan` parameter. Since we're hosted
|
||||
inside Quart, we hook the session manager's `run()` async context manager
|
||||
@@ -346,7 +329,31 @@ def mount_mcp(app: Quart) -> None:
|
||||
from scribe.mcp.auth import resolve_bearer
|
||||
|
||||
mcp = build_mcp_server()
|
||||
mcp_asgi = mcp.streamable_http_app()
|
||||
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
|
||||
# The stateful default strands Claude Code after a container redeploy —
|
||||
# it reconnects with the now-unknown session id, the server returns 404,
|
||||
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
|
||||
# so the connection stays dead until a manual /mcp retry. Stateless makes
|
||||
# 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. (Clients
|
||||
# on the 2026-07-28 revision are stateless by protocol and take the SDK's
|
||||
# modern path regardless; this governs the 2025-era handshake path.)
|
||||
#
|
||||
# DNS-rebinding protection is disabled: the SDK's default allow-list is
|
||||
# just localhost variants, which means any deployment behind a reverse
|
||||
# proxy (Traefik with a hostname like devassistant.traefik.internal,
|
||||
# Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
|
||||
# model that protection addresses — a malicious browser page rebinding
|
||||
# DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
|
||||
# behind a reverse proxy with bearer-token auth as the real security
|
||||
# boundary.
|
||||
mcp_asgi = mcp.streamable_http_app(
|
||||
stateless_http=True,
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False,
|
||||
),
|
||||
)
|
||||
app.mcp_instance = mcp
|
||||
|
||||
@app.before_serving
|
||||
@@ -416,11 +423,11 @@ def mount_mcp(app: Quart) -> None:
|
||||
if scope["type"] == "http":
|
||||
path = scope.get("path", "")
|
||||
if path == "/mcp" or path.startswith("/mcp/"):
|
||||
# Don't rewrite the path: FastMCP's streamable_http_app mounts
|
||||
# Don't rewrite the path: the SDK's streamable_http_app mounts
|
||||
# its handler at /mcp by default. If we strip the prefix to "/",
|
||||
# FastMCP's internal routing returns 404 because there's no
|
||||
# handler at "/" — only at "/mcp". Pass the scope through
|
||||
# untouched and let FastMCP's own routing match.
|
||||
# its internal routing returns 404 because there's no handler
|
||||
# at "/" — only at "/mcp". Pass the scope through untouched and
|
||||
# let the SDK's own routing match.
|
||||
return await auth_wrapped(scope, receive, send)
|
||||
return await original_asgi(scope, receive, send)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""MCP tool implementations.
|
||||
|
||||
Each tool module exposes a `register(mcp)` function that attaches its tools
|
||||
to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
to an MCPServer instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
@@ -13,7 +13,7 @@ from scribe.mcp.tools import (
|
||||
|
||||
|
||||
def register_all(mcp) -> None:
|
||||
"""Register every tool module's tools on the given FastMCP instance."""
|
||||
"""Register every tool module's tools on the given MCPServer instance."""
|
||||
search.register(mcp)
|
||||
retrieval_tuning.register(mcp)
|
||||
wide_net.register(mcp)
|
||||
|
||||
@@ -537,7 +537,7 @@ _ITEM_KEYS = {"title", "body", "type", "status", "priority", "kind", "tags", "sy
|
||||
def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc.BatchItem]:
|
||||
"""Parse the door's plain dicts into BatchItems, refusing unknown keys.
|
||||
|
||||
Strict for the same reason StrictArgsFastMCP is (#2709): a misspelt key
|
||||
Strict for the same reason StrictArgsMCPServer is (#2709): a misspelt key
|
||||
silently dropped — `milestone` for a per-record milestone, `desc` for body —
|
||||
creates a record that looks right and is missing what the caller sent.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user