Skip to content

Commit 23ce681

Browse files
committed
Return 405 for listen-mode GET on stateless streamable HTTP transport
Stateless mode never sends server-initiated messages, so the standalone GET stream previously opened a 200 text/event-stream response and held it open with zero bytes, hanging clients. Add a stateless flag to StreamableHTTPServerTransport and answer 405 for the listen-mode GET when set, per the 2025-03-26 spec. Fixes #3492.
1 parent f1b6589 commit 23ce681

3 files changed

Lines changed: 38 additions & 0 deletions

File tree

‎src/mcp/server/streamable_http.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def __init__(
169169
security_settings: TransportSecuritySettings | None = None,
170170
retry_interval: int | None = None,
171171
idle_timeout: float | None = None,
172+
stateless: bool = False,
172173
) -> None:
173174
"""Initialize a new StreamableHTTP server transport.
174175
@@ -196,6 +197,10 @@ def __init__(
196197
(available once `connect()` has been entered) around the session's
197198
message loop to end the session when it fires. Default is None: no
198199
`idle_scope`, the session never expires.
200+
stateless: If True, this transport never sends server-initiated messages, so the
201+
listen-mode GET (`Accept: text/event-stream`) answers 405 Method Not
202+
Allowed instead of opening a stream that would otherwise sit open
203+
forever with nothing to write. Default is False.
199204
200205
Raises:
201206
ValueError: If the session ID contains invalid characters, or if `idle_timeout`
@@ -211,6 +216,7 @@ def __init__(
211216
self._event_store = event_store
212217
self._security = TransportSecurityMiddleware(security_settings)
213218
self._retry_interval = retry_interval
219+
self._stateless = stateless
214220
self._request_streams: dict[
215221
RequestId,
216222
tuple[
@@ -736,6 +742,18 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
736742
if writer is None: # pragma: no cover
737743
raise ValueError("No read stream writer available. Ensure connect() is called first.")
738744

745+
if self._stateless:
746+
# A stateless transport never sends server-initiated messages, so the
747+
# listen stream would sit open with nothing to write. Per the
748+
# 2025-03-26 spec, a server that doesn't support server-initiated
749+
# messages MUST answer the listen-mode GET with 405.
750+
response = self._create_error_response(
751+
"Method Not Allowed: This server does not support server-initiated messages",
752+
HTTPStatus.METHOD_NOT_ALLOWED,
753+
)
754+
await response(request.scope, request.receive, send)
755+
return
756+
739757
# Validate Accept header - must include text/event-stream
740758
_, has_sse = check_accept_headers(request)
741759

‎src/mcp/server/streamable_http_manager.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ async def _handle_stateless_request(
214214
is_json_response_enabled=self.json_response,
215215
event_store=None, # No event store in stateless mode
216216
security_settings=self.security_settings,
217+
stateless=True,
217218
)
218219

219220
# Start server in a new task

‎tests/shared/test_streamable_http.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ async def running_app(
333333
event_store: EventStore | None = None,
334334
retry_interval: int | None = None,
335335
server: Server[Any] | None = None,
336+
stateless: bool = False,
336337
) -> AsyncIterator[Starlette]:
337338
"""Serve the test server's streamable HTTP app in process for the duration.
338339
@@ -341,6 +342,7 @@ async def running_app(
341342
event_store: Optional event store for testing resumability.
342343
retry_interval: Retry interval in milliseconds for SSE polling.
343344
server: Server to mount; defaults to the file's shared test server.
345+
stateless: If True, run the session manager in stateless mode.
344346
"""
345347
# DNS-rebinding protection validates Host/Origin headers against a network attack that cannot
346348
# exist for an in-process app; the protection itself is pinned by
@@ -349,6 +351,7 @@ async def running_app(
349351
app=server if server is not None else _create_server(),
350352
event_store=event_store,
351353
json_response=is_json_response_enabled,
354+
stateless=stateless,
352355
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
353356
retry_interval=retry_interval,
354357
)
@@ -384,6 +387,13 @@ async def json_app() -> AsyncIterator[Starlette]:
384387
yield app
385388

386389

390+
@pytest.fixture
391+
async def stateless_app() -> AsyncIterator[Starlette]:
392+
"""The test server's app in stateless mode."""
393+
async with running_app(stateless=True) as app:
394+
yield app
395+
396+
387397
@pytest.fixture
388398
def event_store() -> SimpleEventStore:
389399
"""Create a test event store."""
@@ -902,6 +912,15 @@ async def test_get_sse_stream(basic_app: Starlette) -> None:
902912
assert second_get.status_code == 409
903913

904914

915+
@pytest.mark.anyio
916+
async def test_get_sse_stream_returns_405_when_stateless(stateless_app: Starlette) -> None:
917+
"""A stateless server never pushes, so the listen-mode GET answers 405 instead of hanging (#3492)."""
918+
async with make_client(stateless_app) as client:
919+
with anyio.fail_after(5):
920+
get_response = await client.get("/mcp", headers={"Accept": "text/event-stream"})
921+
assert get_response.status_code == 405
922+
923+
905924
@pytest.mark.anyio
906925
async def test_get_validation(basic_app: Starlette) -> None:
907926
"""A GET without an Accept header covering text/event-stream is rejected with 406."""

0 commit comments

Comments
 (0)