Skip to content

fix(server): return 405 on GET/DELETE in stateless HTTP mode#2509

Draft
faridun-ag2 wants to merge 1 commit into
modelcontextprotocol:mainfrom
faridun-ag2:fix/2474-stateless-405-get-delete
Draft

fix(server): return 405 on GET/DELETE in stateless HTTP mode#2509
faridun-ag2 wants to merge 1 commit into
modelcontextprotocol:mainfrom
faridun-ag2:fix/2474-stateless-405-get-delete

Conversation

@faridun-ag2

Copy link
Copy Markdown

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_request short-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. If request.method is GET or DELETE, return a JSON-RPC formatted 405 with Allow: POST and bail out before transport creation.
  • tests/server/test_streamable_http_manager.py — three new tests:
    • test_stateless_get_returns_405 — status, Allow header, JSON-RPC error body
    • test_stateless_delete_returns_405 — same checks for DELETE
    • test_stateless_get_does_not_create_transport — asserts no StreamableHTTPServerTransport is instantiated for a stateless GET

Design notes

  • Layer placement. The guard lives in the manager, not the transport. The manager already owns the stateless/stateful routing decision; StreamableHTTPServerTransport stays mode-agnostic.
  • Response format. Mirrors the existing 404 "Session not found" handler (JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=INVALID_REQUEST, ...)) serialized via model_dump_json(by_alias=True, exclude_unset=True)), with an added Allow: POST header.
  • Allow header. Returns Allow: POST (not GET, POST, DELETE like the stateful transport's _handle_unsupported_request). The difference is intentional — stateless mode genuinely only supports POST.
  • HEAD/OPTIONS in stateless. Not caught by this guard; they fall through to the transport's _handle_unsupported_request and still return 405. The Allow header from that path advertises GET/DELETE that stateless doesn't actually support — minor inconsistency, happy to extend the guard if reviewers prefer.
  • No new imports. Request, Response, HTTPStatus, INVALID_REQUEST, ErrorData, JSONRPCError were all already imported in this module.

Test plan

  • uv run --frozen pytest tests/server/test_streamable_http_manager.py — 14/14 pass
  • uv run --frozen pytest tests/ -k stateless — 13/13 pass
  • uv run --frozen pytest tests/server/ — 477/477 pass (no regressions)
  • uv run --frozen ruff format + ruff check — clean
  • uv run --frozen pyright — 0 errors
  • Targeted coverage on streamable_http_manager.py — 100% (branch-covered)
  • strict-no-cover — clean
  • pre-commit run on changed files — all applicable hooks pass

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
wiggzz added a commit to dbt-labs/mcp-python-sdk that referenced this pull request May 1, 2026
Cherry-pick modelcontextprotocol#2509

Co-authored-by: Will James <will.james@dbtlabs.com>
wiggzz added a commit to dbt-labs/mcp-python-sdk that referenced this pull request May 5, 2026
Cherry-pick modelcontextprotocol#2509

Co-authored-by: Will James <will.james@dbtlabs.com>
@Explorer-64

Copy link
Copy Markdown

Adding a real-world data point in support of merging this.

We hosted an MCP server on Cloud Run with stateless_http=True and hit the exact issue this PR fixes. Cloud Run bills per-second for active requests, so idle GET SSE connections cost money even when the server is doing nothing. With a single connected client: Cloud Run's 301-second request timeout triggers a disconnect, the client immediately reconnects, and the cycle runs continuously at roughly $1-2/day. Zero tool calls, constant billing.

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 Imagcon hosted MCP server runs in stateless mode and does not support
persistent SSE connections. Your client is trying to open a long-lived GET
stream, which is not supported here.

Tool calls (POST requests) work fine, but most IDE clients require SSE to
connect, which means you need the local package instead.

Switch to the local package (takes ~30 seconds):
  1. Get a free API key: https://imagcon.app/api-keys
  2. Claude Code: claude mcp add imagcon -- uvx imagcon-mcp --api-key ic_live_...
  3. Cursor: add {"command":"uvx","args":["imagcon-mcp","--api-key","ic_live_..."]}
     to .cursor/mcp.json

Full setup for all clients: https://imagcon.app/.well-known/blueprint.txt

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 message field in the JSON-RPC error body that is specific enough for an agent runtime to act on, not just log. Something like "SSE not supported in stateless mode. Use POST for tool calls, or switch to a local stdio transport." would give agents enough context to either retry correctly or surface a useful error to the user.

dbt-labs already cherry-picked this into their fork. The billing angle gives another concrete reason to resolve the conflicts and get this merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return 405 on GET when stateless_http=True

2 participants