From d9d65b18df5e382bfd2034007bb735a27c76d035 Mon Sep 17 00:00:00 2001 From: Haseeb Mohammed Afsar Date: Thu, 16 Jul 2026 10:08:49 -0500 Subject: [PATCH] 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) --- src/mcp/server/streamable_http.py | 20 +++++++++++++++++++ tests/server/test_streamable_http_security.py | 7 ++++--- tests/shared/test_streamable_http.py | 20 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index d316345c7e..1fb4fc1f03 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -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): + 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 diff --git a/tests/server/test_streamable_http_security.py b/tests/server/test_streamable_http_security.py index e83289ee34..6783087082 100644 --- a/tests/server/test_streamable_http_security.py +++ b/tests/server/test_streamable_http_security.py @@ -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 body = response.json() - assert "Missing session ID" in body["error"]["message"] + assert "Method Not Allowed" in body["error"]["message"] diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index d7eeccdfdb..e1c7d0c3b0 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -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]: