Skip to content

Commit 221aa6d

Browse files
faridun-ag2wiggzz
andcommitted
fix(server): return 405 on GET/DELETE in stateless HTTP mode
Cherry-pick modelcontextprotocol#2509 Co-authored-by: Will James <will.james@dbtlabs.com>
1 parent 3282c44 commit 221aa6d

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp"
3-
version = "1.27.0.post1"
3+
version = "1.27.0.post2"
44
description = "Model Context Protocol SDK"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/mcp/server/streamable_http_manager.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,29 @@ async def _handle_stateless_request(
201201
receive: ASGI receive function
202202
send: ASGI send function
203203
"""
204+
# In stateless mode, only POST is meaningful. GET (SSE stream) and DELETE
205+
# (session termination) both require session state that stateless mode does
206+
# not maintain, so reject them with 405 before creating a transport.
207+
request = Request(scope, receive)
208+
if request.method in ("GET", "DELETE"):
209+
logger.debug(f"Stateless mode: rejecting {request.method} with 405")
210+
error_response = JSONRPCError(
211+
jsonrpc="2.0",
212+
id="",
213+
error=ErrorData(
214+
code=INVALID_REQUEST,
215+
message=(f"Method Not Allowed: {request.method} is not supported in stateless mode"),
216+
),
217+
)
218+
response = Response(
219+
content=error_response.model_dump_json(by_alias=True, exclude_unset=True),
220+
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
221+
headers={"Allow": "POST"},
222+
media_type="application/json",
223+
)
224+
await response(scope, receive, send)
225+
return
226+
204227
logger.debug("Stateless mode: Creating new transport for this request")
205228
# No session ID needed in stateless mode
206229
http_transport = StreamableHTTPServerTransport(

tests/server/test_streamable_http_manager.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,116 @@ def test_session_idle_timeout_rejects_non_positive():
390390
def test_session_idle_timeout_rejects_stateless():
391391
with pytest.raises(RuntimeError, match="not supported in stateless"):
392392
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
393+
394+
395+
async def _collect_stateless_response(
396+
method: str,
397+
) -> tuple[Message | None, bytes]:
398+
"""Send a request of the given method to a stateless manager and return
399+
(response.start message, response body)."""
400+
app = Server("test-stateless-method")
401+
manager = StreamableHTTPSessionManager(app=app, stateless=True)
402+
403+
sent_messages: list[Message] = []
404+
response_body = b""
405+
406+
async def mock_send(message: Message):
407+
nonlocal response_body
408+
sent_messages.append(message)
409+
if message["type"] == "http.response.body":
410+
response_body += message.get("body", b"")
411+
412+
scope = {
413+
"type": "http",
414+
"method": method,
415+
"path": "/mcp",
416+
"headers": [
417+
(b"content-type", b"application/json"),
418+
(b"accept", b"application/json, text/event-stream"),
419+
],
420+
}
421+
422+
async def mock_receive(): # pragma: no cover
423+
return {"type": "http.request", "body": b"", "more_body": False}
424+
425+
async with manager.run():
426+
await manager.handle_request(scope, mock_receive, mock_send)
427+
428+
response_start = next(
429+
(msg for msg in sent_messages if msg["type"] == "http.response.start"),
430+
None,
431+
)
432+
return response_start, response_body
433+
434+
435+
@pytest.mark.anyio
436+
async def test_stateless_get_returns_405():
437+
"""GET requests return 405 in stateless mode since SSE streams require session state."""
438+
response_start, response_body = await _collect_stateless_response("GET")
439+
440+
assert response_start is not None
441+
assert response_start["status"] == 405
442+
443+
headers = {name.decode().lower(): value.decode() for name, value in response_start.get("headers", [])}
444+
assert headers.get("allow") == "POST"
445+
446+
error_data = json.loads(response_body)
447+
assert error_data["jsonrpc"] == "2.0"
448+
assert error_data["id"] is None
449+
assert error_data["error"]["code"] == INVALID_REQUEST
450+
assert "GET" in error_data["error"]["message"]
451+
assert "stateless" in error_data["error"]["message"].lower()
452+
453+
454+
@pytest.mark.anyio
455+
async def test_stateless_delete_returns_405():
456+
"""DELETE requests return 405 in stateless mode since there is no session to terminate."""
457+
response_start, response_body = await _collect_stateless_response("DELETE")
458+
459+
assert response_start is not None
460+
assert response_start["status"] == 405
461+
462+
headers = {name.decode().lower(): value.decode() for name, value in response_start.get("headers", [])}
463+
assert headers.get("allow") == "POST"
464+
465+
error_data = json.loads(response_body)
466+
assert error_data["jsonrpc"] == "2.0"
467+
assert error_data["id"] is None
468+
assert error_data["error"]["code"] == INVALID_REQUEST
469+
assert "DELETE" in error_data["error"]["message"]
470+
471+
472+
@pytest.mark.anyio
473+
async def test_stateless_get_does_not_create_transport():
474+
"""A GET in stateless mode should short-circuit without spinning up a transport."""
475+
app = Server("test-stateless-no-transport")
476+
manager = StreamableHTTPSessionManager(app=app, stateless=True)
477+
478+
created_transports: list[StreamableHTTPServerTransport] = []
479+
original_constructor = StreamableHTTPServerTransport
480+
481+
def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport:
482+
transport = original_constructor(*args, **kwargs) # pragma: no cover
483+
created_transports.append(transport) # pragma: no cover
484+
return transport # pragma: no cover
485+
486+
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
487+
async with manager.run():
488+
sent_messages: list[Message] = []
489+
490+
async def mock_send(message: Message):
491+
sent_messages.append(message)
492+
493+
scope = {
494+
"type": "http",
495+
"method": "GET",
496+
"path": "/mcp",
497+
"headers": [(b"accept", b"text/event-stream")],
498+
}
499+
500+
async def mock_receive(): # pragma: no cover
501+
return {"type": "http.request", "body": b"", "more_body": False}
502+
503+
await manager.handle_request(scope, mock_receive, mock_send)
504+
505+
assert created_transports == [], "Stateless GET must not create a transport"

0 commit comments

Comments
 (0)