fix(server): return 405 on GET/DELETE in stateless HTTP mode#2509
fix(server): return 405 on GET/DELETE in stateless HTTP mode#2509faridun-ag2 wants to merge 1 commit into
Conversation
In stateless mode the manager was creating a transport for every GET, opening an SSE stream that could never receive server-initiated messages and idling until timeout — wasteful on serverless platforms. DELETE had the same shape (no session to terminate). Reject GET and DELETE with 405 (Allow: POST) before any transport is spawned. Stateful mode is unchanged. Closes modelcontextprotocol#2474
Cherry-pick modelcontextprotocol#2509 Co-authored-by: Will James <will.james@dbtlabs.com>
Cherry-pick modelcontextprotocol#2509 Co-authored-by: Will James <will.james@dbtlabs.com>
|
Adding a real-world data point in support of merging this. We hosted an MCP server on Cloud Run with The silent failure makes it worse. Clients show "Connected" but tools never populate because the SSE stream never carries anything useful. There is no visible signal that billing is running. The only way to spot it is to check Cloud Run request logs and notice the 301-second request durations cycling every few minutes. Our workaround is a manual ASGI guard that returns 405 before the request reaches the MCP app, which is exactly what this PR does at the right layer. Our response body is plain text with actionable instructions so agents and users know what to do: The message tells clients: SSE is not supported, POST tool calls still work, and here is exactly what to do instead. Without this, clients silently reconnect in a loop with no signal that anything is wrong. The JSON-RPC formatted response in this PR is better than our plain-text approach since MCP-aware clients can parse and surface it directly. One suggestion: consider including a dbt-labs already cherry-picked this into their fork. The billing angle gives another concrete reason to resolve the conflicts and get this merged. |
Summary
In stateless HTTP mode (
stateless_http=True), GET requests to the MCP endpoint were creating a transport and opening an SSE stream that could never receive server-initiated messages, idling until timeout. DELETE had the same shape (no session to terminate). This wastes connections, especially on serverless platforms (Cloud Run, Lambda).This PR makes
StreamableHTTPSessionManager._handle_stateless_requestshort-circuit GET and DELETE with HTTP 405 before any transport is spawned. POST is unchanged. Stateful mode is unchanged.The MCP spec permits this: "Servers MAY return HTTP 405 Method Not Allowed if an SSE stream is not offered at the endpoint." The TypeScript SDK already implements this behavior.
Closes #2474.
What changed
src/mcp/server/streamable_http_manager.py— early-return guard at the top of_handle_stateless_request. Ifrequest.methodisGETorDELETE, return a JSON-RPC formatted 405 withAllow: POSTand bail out before transport creation.tests/server/test_streamable_http_manager.py— three new tests:test_stateless_get_returns_405— status,Allowheader, JSON-RPC error bodytest_stateless_delete_returns_405— same checks for DELETEtest_stateless_get_does_not_create_transport— asserts noStreamableHTTPServerTransportis instantiated for a stateless GETDesign notes
StreamableHTTPServerTransportstays mode-agnostic.JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, ...))serialized viamodel_dump_json(by_alias=True, exclude_unset=True)), with an addedAllow: POSTheader.Allowheader. ReturnsAllow: POST(notGET, POST, DELETElike the stateful transport's_handle_unsupported_request). The difference is intentional — stateless mode genuinely only supports POST._handle_unsupported_requestand still return 405. TheAllowheader from that path advertises GET/DELETE that stateless doesn't actually support — minor inconsistency, happy to extend the guard if reviewers prefer.Request,Response,HTTPStatus,INVALID_REQUEST,ErrorData,JSONRPCErrorwere all already imported in this module.Test plan
uv run --frozen pytest tests/server/test_streamable_http_manager.py— 14/14 passuv run --frozen pytest tests/ -k stateless— 13/13 passuv run --frozen pytest tests/server/— 477/477 pass (no regressions)uv run --frozen ruff format+ruff check— cleanuv run --frozen pyright— 0 errorsstreamable_http_manager.py— 100% (branch-covered)strict-no-cover— cleanpre-commit runon changed files — all applicable hooks pass