Skip to content

Commit 8409a65

Browse files
author
Veerendra Kumar
committed
fix: return 405 for pre-session GET the server won't serve as SSE
The Streamable HTTP spec requires a GET the server does not serve as an SSE stream to get 405 Method Not Allowed, but in stateful mode a pre-session GET returned 400 (missing session ID) instead. Only-405 is what client transports (e.g. the TypeScript SDK's SSE probe) treat as the graceful fall-through to POST, so stock servers aborted those handshakes before initialize. Return 405 with Allow: GET, POST, DELETE for session-less GETs in stateful mode, before Accept validation, matching the Allow value of _handle_unsupported_request. (The 406-for-wildcard-Accept arm of the report is already fixed on main via check_accept_headers.) Closes #3102
1 parent 3a6f299 commit 8409a65

4 files changed

Lines changed: 80 additions & 3 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,11 +656,29 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
656656
This allows the server to communicate to the client without the client
657657
first sending data via HTTP POST. The server can send JSON-RPC requests
658658
and notifications on this stream.
659+
660+
Per MCP spec: "The server MUST either return Content-Type: text/event-stream
661+
in response to this HTTP GET, or else return HTTP 405 Method Not Allowed."
659662
"""
660663
writer = self._read_stream_writer
661664
if writer is None: # pragma: no cover
662665
raise ValueError("No read stream writer available. Ensure connect() is called first.")
663666

667+
# Per MCP spec, pre-session GETs that cannot be served as SSE must return
668+
# 405 Method Not Allowed. A GET without a session ID in stateful mode
669+
# cannot establish an SSE stream because no session exists yet.
670+
if self.mcp_session_id and not self._get_session_id(request):
671+
headers = {
672+
"Allow": "GET, POST, DELETE",
673+
}
674+
response = self._create_error_response(
675+
"Method Not Allowed: GET requires an established session",
676+
HTTPStatus.METHOD_NOT_ALLOWED,
677+
headers=headers,
678+
)
679+
await response(request.scope, request.receive, send)
680+
return
681+
664682
# Validate Accept header - must include text/event-stream
665683
_, has_sse = check_accept_headers(request)
666684

tests/interaction/_requirements.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3094,6 +3094,16 @@ def __post_init__(self) -> None:
30943094
transports=("streamable-http",),
30953095
note="Only observable over HTTP: 405 is an HTTP status code.",
30963096
),
3097+
"hosting:http:pre-session-get-405": Requirement(
3098+
source=f"{SPEC_BASE_URL}/basic/transports#receiving-messages-from-the-server",
3099+
behavior=(
3100+
"A GET without a session ID in stateful mode returns 405 Method Not Allowed. "
3101+
"Per MCP spec: 'The server MUST either return Content-Type: text/event-stream in response "
3102+
"to this HTTP GET, or else return HTTP 405 Method Not Allowed.'"
3103+
),
3104+
transports=("streamable-http",),
3105+
note="Only observable over HTTP: 405 is an HTTP status code. Fixes issue #3102.",
3106+
),
30973107
"hosting:http:no-broadcast": Requirement(
30983108
source=f"{SPEC_BASE_URL}/basic/transports#multiple-connections",
30993109
behavior=(

tests/interaction/transports/test_hosting_http.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,54 @@ async def test_unsupported_http_methods_return_405() -> None:
9595
assert (patch.status_code, patch.headers.get("allow")) == snapshot((405, "GET, POST, DELETE"))
9696

9797

98+
@requirement("hosting:http:pre-session-get-405")
99+
async def test_pre_session_get_returns_405() -> None:
100+
"""A GET without a session ID in stateful mode returns 405 Method Not Allowed.
101+
102+
Per MCP spec: "The server MUST either return Content-Type: text/event-stream in response
103+
to this HTTP GET, or else return HTTP 405 Method Not Allowed." A pre-session GET cannot
104+
establish an SSE stream, so 405 is the spec-mandated response.
105+
106+
See: https://github.com/modelcontextprotocol/python-sdk/issues/3102
107+
"""
108+
async with mounted_app(_server()) as (http, _):
109+
# Test with various Accept headers - all should return 405 for pre-session GET
110+
# Accept: */* (wildcard)
111+
response_wildcard = await http.get("/mcp", headers={"accept": "*/*", "mcp-protocol-version": "2025-11-25"})
112+
# Accept: application/json (no SSE)
113+
response_json = await http.get(
114+
"/mcp", headers={"accept": "application/json", "mcp-protocol-version": "2025-11-25"}
115+
)
116+
# No Accept header at all
117+
response_no_accept = await http.get("/mcp", headers={"mcp-protocol-version": "2025-11-25"})
118+
# Accept: text/event-stream (correct, but still no session)
119+
response_sse = await http.get(
120+
"/mcp", headers={"accept": "text/event-stream", "mcp-protocol-version": "2025-11-25"}
121+
)
122+
123+
# All pre-session GETs must return 405 with Allow header listing supported methods
124+
assert (response_wildcard.status_code, response_wildcard.headers.get("allow")) == snapshot(
125+
(405, "GET, POST, DELETE")
126+
)
127+
assert "Method Not Allowed" in response_wildcard.json()["error"]["message"]
128+
assert response_wildcard.json()["error"]["code"] == -32600 # INVALID_REQUEST
129+
130+
assert (response_json.status_code, response_json.headers.get("allow")) == snapshot(
131+
(405, "GET, POST, DELETE")
132+
)
133+
assert "Method Not Allowed" in response_json.json()["error"]["message"]
134+
135+
assert (response_no_accept.status_code, response_no_accept.headers.get("allow")) == snapshot(
136+
(405, "GET, POST, DELETE")
137+
)
138+
assert "Method Not Allowed" in response_no_accept.json()["error"]["message"]
139+
140+
assert (response_sse.status_code, response_sse.headers.get("allow")) == snapshot(
141+
(405, "GET, POST, DELETE")
142+
)
143+
assert "Method Not Allowed" in response_sse.json()["error"]["message"]
144+
145+
98146
@requirement("hosting:http:accept-406")
99147
async def test_missing_accept_media_types_return_406() -> None:
100148
"""A POST whose Accept header lacks both required types, or a GET lacking text/event-stream, returns 406."""

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 but fails because GET requires an established session.
128+
# Per MCP spec, pre-session GETs return 405 Method Not Allowed (issue #3102).
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"]

0 commit comments

Comments
 (0)