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

- 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:
2026-09-24 07:21:27 -04:00
co-authored by Claude Opus 5.5
parent 4502f0a1ae
commit c26b7f248e
9 changed files with 153 additions and 95 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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.
"""