Skip to content

Return 405 on GET when stateless_http=True #2474

Description

@itomise

Initial Checks

Description

When a Streamable HTTP server is configured with stateless_http=True, GET /mcp is still accepted and opens an SSE stream that has no session context and can never receive server-initiated messages — a dead-end.

The MCP Streamable HTTP spec explicitly permits returning 405 Method Not Allowed when the server does not offer an SSE stream at the endpoint
(spec):

The server MUST return HTTP 405 Method Not Allowed if an SSE stream is
not offered at the endpoint.

A stateless server cannot offer one — there is no session to push to — so GET should 405. The TypeScript SDK already behaves this way in its stateless example.

A prior PR (#2262) proposed essentially this change but was closed for lack of a corresponding issue per CONTRIBUTING.md.
Opening this issue to agree on scope so the fix can be re-submitted.

Example Code

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-server", stateless_http=True, json_response=True)

@mcp.tool()
def ping() -> str:
    return "pong"

app = mcp.streamable_http_app()

A couple of clarifications for triage:

Current vs expected behavior

Today (v1.27.0):

$ curl -i -X GET http://localhost:8000/mcp
HTTP/1.1 200 OK
content-type: text/event-stream
... (stream idles until platform timeout)

Expected:

$ curl -i -X GET http://localhost:8000/mcp
HTTP/1.1 405 Method Not Allowed
Allow: POST

Concrete impact

The idle GET SSE stream holds a long-lived connection per client with no useful payload, exhausting concurrent connection limits on serverless platforms like Cloud Run. Background previously raised in #2232 / #1941.

Proposed resolution

PR #2262 already has a working implementation. Once this issue is labeled ready for work, reopening it (narrowed to GET per this issue's scope) should be sufficient — no new PR needed.

Python & MCP Python SDK

1.27.0

Activity

  1. faridun-m commented on Apr 22, 2026

    @faridun-m

    I'd like to pick this up. The fix is straightforward: in _handle_stateless_request (streamable_http_manager.py, line ~153), check the request method before creating a transport. If it's GET (or DELETE), return 405 with Allow: POST immediately instead of spinning up a dead SSE stream.
    This keeps StreamableHTTPServerTransport unaware of stateless semantics and avoids touching the stateful path.
    The closed PR #2262 had a working implementation. I'll follow the same direction but scope it to the manager layer.
    Could this be labeled ready for work so I can proceed?

  2. sn0wm1ku commented on Jul 10, 2026

    @sn0wm1ku

    We're hitting this too, and the cost impact is significant.

    Setup: a stateless MCP server (stateless_http=True, mcp 1.26.0) on Google Cloud Run, request timeout 3600s, 1 vCPU.

    An authenticated client opens the standalone GET SSE stream. In stateless mode this stream can never receive a server-initiated message (no session to push to), so it idles until Cloud Run force-closes it at the ~3600s request timeout — then the client immediately reconnects, looping around the clock.

    Because Cloud Run bills CPU for the full duration of an in-flight request, one continuously-connected client pins an instance ~24/7 ≈ 86,400 vCPU-seconds/day — about half of Cloud Run's entire monthly free tier (180,000 vCPU-s) burned by a single idle client in one day. Request count is negligible (a few hundred/day); the whole bill is the held GET stream.

    Returning 405 on GET in stateless mode (as proposed here and in #2509) fixes this cleanly — the stream carries no data in stateless mode, and per the Streamable HTTP spec a server MAY decline it with 405, after which clients fall back to POST-only. Would be great to see #2509 rebased and merged; happy to help test.

  3. AlvaroBalbin commented on Jul 11, 2026

    @AlvaroBalbin

    I can reproduce this on current main. A GET with no (or a 2025-handshake) MCP-Protocol-Version header routes through StreamableHTTPSessionManager._handle_stateless_request in src/mcp/server/streamable_http_manager.py, which calls the transport's handle_request → _handle_get_request in src/mcp/server/streamable_http.py. That method has no notion of statelessness (the stateless transport is just built with mcp_session_id=None, event_store=None), so it unconditionally opens the standalone SSE stream and idles. Worth noting the modern path already does the right thing: handle_modern_request in src/mcp/server/_streamable_http_modern.py returns 405 with Allow: POST for any non-POST method, so the gap is really only in the legacy stateless GET path. A fix could either short-circuit GET in _handle_stateless_request before dispatching, or thread a stateless flag into the transport so _handle_get_request can 405 when it can never offer a stream.

  4. shivamsingh-007 commented on Jul 12, 2026

    @shivamsingh-007

    I'd like to work on this. The fix is straightforward: in _handle_stateless_request()\ (\streamable_http_manager.py), check the HTTP method before creating the transport and return 405 with \Allow: POST\ for GET/DELETE. PR #2262 already has a working implementation that just needs to be reopened and scoped to GET per this issue. Let me know if that sounds right or if there's a preferred direction before I start.

  5. added
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Aug 14, 2026
  6. matthewhelmke commented on Oct 9, 2026

    @matthewhelmke

    A data point from a production deployment. We run a stateless_http=True server (mcp 2.3.0) on Google Cloud Run. Each held-open GET stream occupies one Cloud Run request slot until the platform's 300-second request timeout. At 3 instances × 80 concurrent requests, an MCP gateway's GET streams filled every slot, and Cloud Run returned 429 to all requests, including initialize.

    We worked around it by inserting a GET-only Starlette route ahead of the SDK's /mcp route that returns 405 with Allow: POST. Claude Code handled the 405 without retrying. A built-in 405 in stateless mode would make the workaround unnecessary.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions