Skip to content

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

Draft
faridun-ag2 wants to merge 1 commit intomodelcontextprotocol: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 intomodelcontextprotocol: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
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

1 participant