Skip to content

Commit 67d4596

Browse files
committed
fix: return 405 for GET/DELETE in stateless streamable-http mode
In stateless mode, GET opens a session-less SSE stream that idles until the platform kills it (e.g. Cloud Run 120s timeout). Clients like mcp-remote auto-reconnect, creating an infinite loop of billed CPU. The TypeScript SDK correctly returns 405 for GET and DELETE in stateless mode. This aligns the Python SDK with that behavior and the MCP spec (GET is MAY, not MUST; stateless servers have no session context for server-initiated notifications).
1 parent 62eb08e commit 67d4596

File tree

2 files changed

+71
-0
lines changed

2 files changed

+71
-0
lines changed

src/mcp/server/streamable_http_manager.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,21 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
152152

153153
async def _handle_stateless_request(self, scope: Scope, receive: Receive, send: Send) -> None:
154154
"""Process request in stateless mode - creating a new transport for each request."""
155+
# In stateless mode only POST is meaningful. GET would open a
156+
# session-less SSE stream that idles until the platform kills it,
157+
# and DELETE has no session to terminate. Return 405 per spec.
158+
request = Request(scope, receive)
159+
if request.method != "POST":
160+
from starlette.responses import Response
161+
162+
response = Response(
163+
content="Method Not Allowed",
164+
status_code=405,
165+
headers={"Allow": "POST"},
166+
)
167+
await response(scope, receive, send)
168+
return
169+
155170
logger.debug("Stateless mode: Creating new transport for this request")
156171
# No session ID needed in stateless mode
157172
http_transport = StreamableHTTPServerTransport(

tests/server/test_streamable_http_manager.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,59 @@ def test_session_idle_timeout_rejects_non_positive():
413413
def test_session_idle_timeout_rejects_stateless():
414414
with pytest.raises(RuntimeError, match="not supported in stateless"):
415415
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
416+
417+
418+
@pytest.mark.anyio
419+
async def test_stateless_get_returns_405():
420+
"""GET /mcp must return 405 in stateless mode (no SSE stream to open)."""
421+
app = Server("test-stateless-get")
422+
manager = StreamableHTTPSessionManager(app=app, stateless=True)
423+
424+
async with manager.run():
425+
sent_messages: list[Message] = []
426+
427+
async def mock_send(message: Message):
428+
sent_messages.append(message)
429+
430+
scope = {
431+
"type": "http",
432+
"method": "GET",
433+
"path": "/mcp",
434+
"headers": [(b"accept", b"text/event-stream")],
435+
}
436+
437+
async def mock_receive():
438+
return {"type": "http.request", "body": b"", "more_body": False}
439+
440+
await manager.handle_request(scope, mock_receive, mock_send)
441+
442+
assert sent_messages[0]["type"] == "http.response.start"
443+
assert sent_messages[0]["status"] == 405
444+
445+
446+
@pytest.mark.anyio
447+
async def test_stateless_delete_returns_405():
448+
"""DELETE /mcp must return 405 in stateless mode (no session to terminate)."""
449+
app = Server("test-stateless-delete")
450+
manager = StreamableHTTPSessionManager(app=app, stateless=True)
451+
452+
async with manager.run():
453+
sent_messages: list[Message] = []
454+
455+
async def mock_send(message: Message):
456+
sent_messages.append(message)
457+
458+
scope = {
459+
"type": "http",
460+
"method": "DELETE",
461+
"path": "/mcp",
462+
"headers": [],
463+
}
464+
465+
async def mock_receive():
466+
return {"type": "http.request", "body": b"", "more_body": False}
467+
468+
await manager.handle_request(scope, mock_receive, mock_send)
469+
470+
assert sent_messages[0]["type"] == "http.response.start"
471+
assert sent_messages[0]["status"] == 405

0 commit comments

Comments
 (0)