Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,26 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
await response(request.scope, request.receive, send)
return

# Per the Streamable HTTP spec, a GET that the server will not serve as a
# standalone SSE stream MUST be answered with 405 Method Not Allowed:
# "The server MUST either return Content-Type: text/event-stream in
# response to this HTTP GET, or else return HTTP 405 Method Not Allowed,
# indicating that the server does not offer an SSE stream at this endpoint."
# Spec-compliant clients probe with a pre-`initialize` GET and treat 405 as
# the graceful "no SSE, fall through to POST" signal. In stateful mode a GET
# without an established session can never open an SSE stream, so falling
# through to session validation (which returns 400 "Missing session ID")
# makes that probe impossible to satisfy. Return the spec-mandated 405 here.
if self.mcp_session_id and not self._get_session_id(request):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A normal pre-initialize SSE probe now creates and retains a stateful session even though it is rejected. The session manager allocates and starts a transport before this handler returns 405, and the prescribed follow-up POST has no session ID, so it creates a different transport; with the default no idle timeout, the probe transport stays in _server_instances forever. Consider rejecting session-less GET probes before allocating a stateful transport (or explicitly terminating/removing the newly allocated transport) so this handshake path cannot accumulate orphan sessions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/streamable_http.py, line 685:

<comment>A normal pre-initialize SSE probe now creates and retains a stateful session even though it is rejected. The session manager allocates and starts a transport before this handler returns 405, and the prescribed follow-up POST has no session ID, so it creates a different transport; with the default no idle timeout, the probe transport stays in `_server_instances` forever. Consider rejecting session-less GET probes before allocating a stateful transport (or explicitly terminating/removing the newly allocated transport) so this handshake path cannot accumulate orphan sessions.</comment>

<file context>
@@ -672,6 +672,26 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
+        # without an established session can never open an SSE stream, so falling
+        # through to session validation (which returns 400 "Missing session ID")
+        # makes that probe impossible to satisfy. Return the spec-mandated 405 here.
+        if self.mcp_session_id and not self._get_session_id(request):
+            response = self._create_error_response(
+                "Method Not Allowed: No standalone SSE stream is available without an active session; "
</file context>

response = self._create_error_response(
"Method Not Allowed: No standalone SSE stream is available without an active session; "
"POST to initialize a session first",
HTTPStatus.METHOD_NOT_ALLOWED,
headers={"Allow": "POST"},
)
await response(request.scope, request.receive, send)
return

if not await self._validate_request_headers(request, send):
return

Expand Down
7 changes: 4 additions & 3 deletions tests/server/test_streamable_http_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ async def test_streamable_http_security_get_request() -> None:
assert response.text == "Invalid Host header"

response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "127.0.0.1"})
# An allowed host passes security and fails on session validation instead.
assert response.status_code == 400
# An allowed host passes security; a session-less GET the server cannot serve
# as an SSE stream then gets the spec-mandated 405 (not 400).
assert response.status_code == 405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The spec-mandated Allow: POST header returned with the 405 is not asserted. The implementation sets it (line 331 of _streamable_http_modern.py), but the test only checks status code and error message. Add assert response.headers.get("Allow") == "POST" to fully cover the spec-mandated behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/server/test_streamable_http_security.py, line 129:

<comment>The spec-mandated `Allow: POST` header returned with the 405 is not asserted. The implementation sets it (line 331 of `_streamable_http_modern.py`), but the test only checks status code and error message. Add `assert response.headers.get("Allow") == "POST"` to fully cover the spec-mandated behavior.</comment>

<file context>
@@ -124,7 +124,8 @@ async def test_streamable_http_security_get_request() -> None:
-        assert response.status_code == 400
+        # An allowed host passes security; a session-less GET the server cannot serve
+        # as an SSE stream then gets the spec-mandated 405 (not 400).
+        assert response.status_code == 405
         body = response.json()
-        assert "Missing session ID" in body["error"]["message"]
</file context>

body = response.json()
assert "Missing session ID" in body["error"]["message"]
assert "Method Not Allowed" in body["error"]["message"]
20 changes: 20 additions & 0 deletions tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,26 @@ async def test_get_validation(basic_app: Starlette) -> None:
assert "Not Acceptable" in response.text


@pytest.mark.anyio
async def test_get_without_session_returns_405(basic_app: Starlette) -> None:
"""A pre-`initialize` GET that the server won't serve as SSE returns 405, not 400.

Per the Streamable HTTP spec the server MUST answer such a GET with 405 Method
Not Allowed. Spec-compliant clients probe with a session-less GET before
`initialize` to ask "do you offer a standalone SSE stream?" and treat only 405
as the graceful "no SSE, fall through to POST" signal. Returning 400 (missing
session ID) makes that probe impossible to satisfy.
"""
async with make_client(basic_app) as client:
response = await client.get(
"/mcp",
headers={"Accept": "text/event-stream"},
)
assert response.status_code == 405
assert "Method Not Allowed" in response.text
assert response.headers.get("Allow") == "POST"


# Client-specific fixtures
@pytest.fixture
async def initialized_client_session(basic_app: Starlette) -> AsyncIterator[ClientSession]:
Expand Down
Loading