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,vultureon CI's scopes — clean- Verified live in a container:
serverInfo.versionis populated,build_http_app()returns independent apps, and bothhttpxandhttpx2import
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:
stateis interpolated into the authorization redirect without URL-encoding (views.py), allowing parameter injection into a registered client's callback/mcp/revokenever revokes access tokens, becauseload_access_tokenreports the user id asclient_idand the SDK's revocation handler gates on aclient_idmatch- the authorization code is read and deleted non-atomically, so there is no reuse detection
signing.dumps/loadsuse 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.