feat(mcp): port the MCP server to SDK v2

Summary

Ports apps/mcp from MCP SDK v1 to v2 and lifts the mcp[cli]<2 cap.

The v2 SDK renames FastMCP to MCPServer and moves it to mcp.server.mcpserver; mcp.server.fastmcp survives only as a stub that raises with a pointer to the migration guide. The OAuth surface is unchanged — OAuthAuthorizationServerProvider keeps the same ten methods with the same signatures, and the one addition (exchange_identity_assertion, the SEP-990 ID-JAG grant) ships a default that rejects the grant, so GlitchTipOAuthProvider needed no changes. All 17 @mcp.tool() registrations are untouched, and their generated inputSchema/outputSchema are byte-identical across the two SDK versions.

Four things needed more than an import swap.

Transport settings became per-app, not per-server

stateless_http and transport_security moved off the server constructor onto streamable_http_app(). They therefore no longer apply to every transport automatically, and the SDK's default is not neutral: build a transport without them and it enables DNS-rebinding protection with a localhost-only allow-list, which answers 421 to every non-local deployment.

apps/mcp/tests/test_http.py builds its own app, so passing the settings only at the production call site made the test app reject a non-localhost Host:

AssertionError: 421 != 200
FAIL: test_non_localhost_host_header — "Non-localhost Host header should NOT trigger 421."

Both settings now live in a TRANSPORT_SECURITY constant and a build_http_app() factory in apps/mcp/server.py, and glitchtip/asgi.py and the tests build through it.

The initialize handshake lost the server version

v1 resolved server_version=self.version if self.version else pkg_version("mcp"). v2 is a bare server_version=self.version with no fallback, so an unset version reaches clients as "". The server now reports GLITCHTIP_VERSION, which the REST API and startup banner already report and which is more useful to a client than the SDK's own version.

A v1 workaround in the tests became dead code

The tests nulled the lazily-cached session manager to force a fresh app per case. In v2 streamable_http_app() constructs one unconditionally on every call — there is no cache to bust — so the reset is gone rather than repointed at v2's internal location. Verified: two consecutive builds yield distinct apps and distinct managers.

httpx left the lockfile

httpx was never a declared dependency; it arrived transitively via mcp v1, and v2 replaces it with httpx2. Moving the SDK therefore removed it from the environment and broke benchmarks/bench_ingest_memory.py, benchmarks/run_ingest_bench.sh and benchmarks/run_concurrency_bench.sh, which import it — silently, since nothing in CI runs them. It is now declared in the dev group, so the bench harness owns its own dependency.

Dependency bound

mcp[cli]>=2.1.1,<3 rather than unbounded. The unbounded >=1.9 is what let a routine uv lock --upgrade cross a major and break the tree; this SDK moves its server API across majors, so a major bump should be a deliberate code change.

Also

The comments justifying apps/oauth/mcp_provider.py's existence as a separate module claimed the MCP stack costs ~22 MiB resident. Measured as the marginal cost of importing apps.mcp.server into a booted web process, it is 34.0 MiB on v1 and 47.6 MiB on v2 — the figure was already stale before this port. Both comments now say ~48 MiB and name opentelemetry-api, which v2 adds as a hard dependency. Plus a return annotation on the factory, hoisted function-local test imports, and six copies of the ASGI client construction collapsed into one helper.

Testing

Run in an isolated worktree and compose project against Postgres 18 + Valkey 9:

  • apps.mcp apps.oauth glitchtip.test_asgi_dispatcher — 107 tests, OK
  • Full suite — Ran 1622 tests, OK (skipped=2)
  • ruff check, ruff format --check, vulture on CI's scopes — clean
  • Verified live in a container: serverInfo.version is populated, build_http_app() returns independent apps, and both httpx and httpx2 import

Out of scope

Reviewing this change surfaced several pre-existing issues in apps/oauth that this MR does not touch and deliberately does not fix, to keep a dependency port separate from behaviour changes:

  • state is interpolated into the authorization redirect without URL-encoding (views.py), allowing parameter injection into a registered client's callback
  • /mcp/revoke never revokes access tokens, because load_access_token reports the user id as client_id and the SDK's revocation handler gates on a client_id match
  • the authorization code is read and deleted non-atomically, so there is no reuse detection
  • signing.dumps/loads use the default salt
  • the consent screen identifies the requesting client only by an opaque UUID, under open dynamic registration

Separately, and worth a decision rather than a silent default: MCPDjangoDispatcher routes /mcp and /.well-known/* to the Starlette sub-app before Django runs, so ALLOWED_HOSTS never applies to those paths and TransportSecuritySettings is the only host check on the MCP surface. It is disabled, here as on master. Deriving an allow-list from ALLOWED_HOSTS and GLITCHTIP_URL is small, but it can reject deployments that work today, so it is not folded into this port.

AI disclosure: Written with Claude Code (Opus 5). It performed the SDK API comparison, wrote the changes, ran the tests and linters, and ran an adversarial self-review whose findings are folded in above; a human reviewed the result before merge.

Edited by David Burke

Merge request reports

Loading
Loading