diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index d316345c7..1fb4fc1f0 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 e83289ee3..678308708 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 d7eeccdfd..e1c7d0c3b 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]: