Skip to content

Commit d9d65b1

Browse files
Haseeb Mohammed Afsarclaude
andcommitted
fix(streamable-http): return spec-mandated 405 for pre-session GET
A GET the Streamable HTTP server will not serve as a standalone SSE stream must be answered with 405 Method Not Allowed per the transport spec: "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." In stateful mode a session-less GET previously fell through to session validation and returned 400 "Missing session ID". Spec-compliant clients (e.g. the TypeScript SDK's _startOrAuthSse) probe with a pre-initialize GET and treat only 405 as the graceful "no SSE, fall through to POST" signal, so the 400 made that probe impossible to satisfy and aborted the handshake before initialize. Return 405 with `Allow: POST` for a session-less GET that accepts SSE, before session validation. Legitimate clients only GET after initialize (with a session id) and are unaffected. Adds a regression test and updates the security test whose post-Host-check assertion exercised the old 400 path. Closes #3102 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e464f72 commit d9d65b1

3 files changed

Lines changed: 44 additions & 3 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,26 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
672672
await response(request.scope, request.receive, send)
673673
return
674674

675+
# Per the Streamable HTTP spec, a GET that the server will not serve as a
676+
# standalone SSE stream MUST be answered with 405 Method Not Allowed:
677+
# "The server MUST either return Content-Type: text/event-stream in
678+
# response to this HTTP GET, or else return HTTP 405 Method Not Allowed,
679+
# indicating that the server does not offer an SSE stream at this endpoint."
680+
# Spec-compliant clients probe with a pre-`initialize` GET and treat 405 as
681+
# the graceful "no SSE, fall through to POST" signal. In stateful mode a GET
682+
# without an established session can never open an SSE stream, so falling
683+
# through to session validation (which returns 400 "Missing session ID")
684+
# makes that probe impossible to satisfy. Return the spec-mandated 405 here.
685+
if self.mcp_session_id and not self._get_session_id(request):
686+
response = self._create_error_response(
687+
"Method Not Allowed: No standalone SSE stream is available without an active session; "
688+
"POST to initialize a session first",
689+
HTTPStatus.METHOD_NOT_ALLOWED,
690+
headers={"Allow": "POST"},
691+
)
692+
await response(request.scope, request.receive, send)
693+
return
694+
675695
if not await self._validate_request_headers(request, send):
676696
return
677697

tests/server/test_streamable_http_security.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ async def test_streamable_http_security_get_request() -> None:
124124
assert response.text == "Invalid Host header"
125125

126126
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "127.0.0.1"})
127-
# An allowed host passes security and fails on session validation instead.
128-
assert response.status_code == 400
127+
# An allowed host passes security; a session-less GET the server cannot serve
128+
# as an SSE stream then gets the spec-mandated 405 (not 400).
129+
assert response.status_code == 405
129130
body = response.json()
130-
assert "Missing session ID" in body["error"]["message"]
131+
assert "Method Not Allowed" in body["error"]["message"]

tests/shared/test_streamable_http.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,26 @@ async def test_get_validation(basic_app: Starlette) -> None:
854854
assert "Not Acceptable" in response.text
855855

856856

857+
@pytest.mark.anyio
858+
async def test_get_without_session_returns_405(basic_app: Starlette) -> None:
859+
"""A pre-`initialize` GET that the server won't serve as SSE returns 405, not 400.
860+
861+
Per the Streamable HTTP spec the server MUST answer such a GET with 405 Method
862+
Not Allowed. Spec-compliant clients probe with a session-less GET before
863+
`initialize` to ask "do you offer a standalone SSE stream?" and treat only 405
864+
as the graceful "no SSE, fall through to POST" signal. Returning 400 (missing
865+
session ID) makes that probe impossible to satisfy.
866+
"""
867+
async with make_client(basic_app) as client:
868+
response = await client.get(
869+
"/mcp",
870+
headers={"Accept": "text/event-stream"},
871+
)
872+
assert response.status_code == 405
873+
assert "Method Not Allowed" in response.text
874+
assert response.headers.get("Allow") == "POST"
875+
876+
857877
# Client-specific fixtures
858878
@pytest.fixture
859879
async def initialized_client_session(basic_app: Starlette) -> AsyncIterator[ClientSession]:

0 commit comments

Comments
 (0)