Repository navigation
Return 405 on GET /mcp so idle SSE streams stop exhausting Cloud Run - #4181
Merged
Merged
Conversation
In stateless mode the MCP SDK still accepts GET /mcp and holds an empty SSE stream open until Cloud Run's 300-second request timeout. Each stream holds one of the service's 240 request slots (3 instances x 80), and a customer's MCP gateway opened enough of them that Cloud Run returned 429 to every caller (EXP-577). Upstream issue: modelcontextprotocol/python-sdk#2474. The server now builds its Starlette app itself, puts a GET-only route that returns 405 (Allow: POST) ahead of the SDK's /mcp route, and runs it with uvicorn. POST and DELETE still reach the SDK. The source-inspection transport test becomes a functional test of the production app, with pytest-timeout so a regression fails instead of hanging. The smoke test gains a GET check against the built image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Deploy Preview for ornate-narwhal-088216 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The hosted docs MCP server returned
429 Rate exceededto a customer's MCP gateway, and to every other client using the server at the time (EXP-577). The cause was server-to-client SSE streams that the server accepts but never uses. This PR makes the server refuse them with405, which the MCP spec defines for exactly this case.Cause
GET /mcpfrom clients on the 2025 protocol versions. It holds the stream open with a keepalive ping every 15 seconds and never sends anything, because a stateless server has nothing to send.initialize.This is an upstream SDK bug: modelcontextprotocol/python-sdk#2474, still open, with no fix in 2.3.0.
Changes
scripts/mcp-server.pybuild_http_app()builds the SDK's Starlette app with the samestateless_http=Trueandjson_response=Truesettings as before. It then inserts a GET-only route ahead of the SDK's/mcproute, and that route returns405withAllow: POST.custom_route()can't do this, because it adds routes after/mcp.uvicorn.run()instead ofserver.run(). It passes the same host, port, and log level the SDK's runner passes, and uvicorn still readsFORWARDED_ALLOW_IPSfrom the environment, so the fix(mcp): keep HTTP redirects on https behind Cloud Run (DOCS-99 root cause) #3704 redirect fix is unaffected.scripts/test_mcp_transport.pyserver.run(... stateless_http=True)call, is replaced by functional tests that send requests to the app production serves:405withAllow: POST, with and without a protocol-version header.initializereturns a JSON result and mints noMcp-Session-Id.tools/listworks without a session.TestClientruns as a context manager so the app's lifespan starts the SDK's session manager. Without it, every POST returns 500..github/workflows/mcp-image-smoke-test.yamlunit-testjob installspytest-timeout. If the 405 route regresses, the GET opens a stream that never ends, and the test hangs instead of failing. The GET test has a 10-second timeout, and the job has notimeout-minutesof its own.scripts/smoke-test-mcp.sh405. The comment that explained why the script never sends GET is updated.Testing
pytest scripts/test_mcp_server.py scripts/test_mcp_transport.py scripts/test_compile_docs.py: 48 passed.pytest-timeoutfails them after 10 seconds.stateless_http=False, theinitializeandtools/listtests fail.json_response=False, the same two tests fail.mainwith CI's placeholder artifacts.smoke-test-mcp.shpasses all five checks on this branch. Onmain, Check 5 fails with a 200.initialize, got the 405, didn't retry, and completed asearch_docscall.search_docs. It never sends a GET.POST /mcp/withX-Forwarded-Proto: httpsstill redirects tohttps://mcp.edu.chainguard.dev/mcp.A code review of the Envoy AI Gateway and the MCP TypeScript SDK found that both treat a 405 on GET as "no stream" and don't retry. Cursor's own client, Goose, and older TypeScript SDK versions weren't checked.
Not in this PR
iac/.httpRequest.status=429on themcp-serverservice for 2026-09-30, 16:17 to 16:22 UTC would confirm where the 429s came from.After merge
The
scripts/**push trigger redeploys the hosted server. Then aGETtohttps://mcp.edu.chainguard.dev/mcpwithMCP-Protocol-Version: 2025-11-25should return405right away instead of holding the stream open.Refs EXP-577, CUS-1340
🤖 Generated with Claude Code
Created in collaboration with Claude Code running Opus 5.5 on 2026-10-09.