Skip to content

Commit 2273fe1

Browse files
committed
fix(server): return 405 on GET/DELETE in stateless HTTP mode
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 #2474
1 parent 3d7b311 commit 2273fe1

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

src/mcp/server/streamable_http_manager.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,29 @@ 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 (SSE stream) and DELETE
156+
# (session termination) both require session state that stateless mode does
157+
# not maintain, so reject them with 405 before creating a transport.
158+
request = Request(scope, receive)
159+
if request.method in ("GET", "DELETE"):
160+
logger.debug(f"Stateless mode: rejecting {request.method} with 405")
161+
error_response = JSONRPCError(
162+
jsonrpc="2.0",
163+
id=None,
164+
error=ErrorData(
165+
code=INVALID_REQUEST,
166+
message=(f"Method Not Allowed: {request.method} is not supported in stateless mode"),
167+
),
168+
)
169+
response = Response(
170+
content=error_response.model_dump_json(by_alias=True, exclude_unset=True),
171+
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
172+
headers={"Allow": "POST"},
173+
media_type="application/json",
174+
)
175+
await response(scope, receive, send)
176+
return
177+
155178
logger.debug("Stateless mode: Creating new transport for this request")
156179
# No session ID needed in stateless mode
157180
http_transport = StreamableHTTPServerTransport(

tests/server/test_streamable_http_manager.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,116 @@ 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+
async def _collect_stateless_response(
419+
method: str,
420+
) -> tuple[Message | None, bytes]:
421+
"""Send a request of the given method to a stateless manager and return
422+
(response.start message, response body)."""
423+
app = Server("test-stateless-method")
424+
manager = StreamableHTTPSessionManager(app=app, stateless=True)
425+
426+
sent_messages: list[Message] = []
427+
response_body = b""
428+
429+
async def mock_send(message: Message):
430+
nonlocal response_body
431+
sent_messages.append(message)
432+
if message["type"] == "http.response.body":
433+
response_body += message.get("body", b"")
434+
435+
scope = {
436+
"type": "http",
437+
"method": method,
438+
"path": "/mcp",
439+
"headers": [
440+
(b"content-type", b"application/json"),
441+
(b"accept", b"application/json, text/event-stream"),
442+
],
443+
}
444+
445+
async def mock_receive(): # pragma: no cover
446+
return {"type": "http.request", "body": b"", "more_body": False}
447+
448+
async with manager.run():
449+
await manager.handle_request(scope, mock_receive, mock_send)
450+
451+
response_start = next(
452+
(msg for msg in sent_messages if msg["type"] == "http.response.start"),
453+
None,
454+
)
455+
return response_start, response_body
456+
457+
458+
@pytest.mark.anyio
459+
async def test_stateless_get_returns_405():
460+
"""GET requests return 405 in stateless mode since SSE streams require session state."""
461+
response_start, response_body = await _collect_stateless_response("GET")
462+
463+
assert response_start is not None
464+
assert response_start["status"] == 405
465+
466+
headers = {name.decode().lower(): value.decode() for name, value in response_start.get("headers", [])}
467+
assert headers.get("allow") == "POST"
468+
469+
error_data = json.loads(response_body)
470+
assert error_data["jsonrpc"] == "2.0"
471+
assert error_data["id"] is None
472+
assert error_data["error"]["code"] == INVALID_REQUEST
473+
assert "GET" in error_data["error"]["message"]
474+
assert "stateless" in error_data["error"]["message"].lower()
475+
476+
477+
@pytest.mark.anyio
478+
async def test_stateless_delete_returns_405():
479+
"""DELETE requests return 405 in stateless mode since there is no session to terminate."""
480+
response_start, response_body = await _collect_stateless_response("DELETE")
481+
482+
assert response_start is not None
483+
assert response_start["status"] == 405
484+
485+
headers = {name.decode().lower(): value.decode() for name, value in response_start.get("headers", [])}
486+
assert headers.get("allow") == "POST"
487+
488+
error_data = json.loads(response_body)
489+
assert error_data["jsonrpc"] == "2.0"
490+
assert error_data["id"] is None
491+
assert error_data["error"]["code"] == INVALID_REQUEST
492+
assert "DELETE" in error_data["error"]["message"]
493+
494+
495+
@pytest.mark.anyio
496+
async def test_stateless_get_does_not_create_transport():
497+
"""A GET in stateless mode should short-circuit without spinning up a transport."""
498+
app = Server("test-stateless-no-transport")
499+
manager = StreamableHTTPSessionManager(app=app, stateless=True)
500+
501+
created_transports: list[StreamableHTTPServerTransport] = []
502+
original_constructor = StreamableHTTPServerTransport
503+
504+
def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport:
505+
transport = original_constructor(*args, **kwargs) # pragma: no cover
506+
created_transports.append(transport) # pragma: no cover
507+
return transport # pragma: no cover
508+
509+
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
510+
async with manager.run():
511+
sent_messages: list[Message] = []
512+
513+
async def mock_send(message: Message):
514+
sent_messages.append(message)
515+
516+
scope = {
517+
"type": "http",
518+
"method": "GET",
519+
"path": "/mcp",
520+
"headers": [(b"accept", b"text/event-stream")],
521+
}
522+
523+
async def mock_receive(): # pragma: no cover
524+
return {"type": "http.request", "body": b"", "more_body": False}
525+
526+
await manager.handle_request(scope, mock_receive, mock_send)
527+
528+
assert created_transports == [], "Stateless GET must not create a transport"

0 commit comments

Comments
 (0)