Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 18 additions & 0 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,11 +656,29 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
This allows the server to communicate to the client without the client
first sending data via HTTP POST. The server can send JSON-RPC requests
and notifications on this stream.

Per MCP 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."
"""
writer = self._read_stream_writer
if writer is None: # pragma: no cover
raise ValueError("No read stream writer available. Ensure connect() is called first.")

# Per MCP spec, pre-session GETs that cannot be served as SSE must return
# 405 Method Not Allowed. A GET without a session ID in stateful mode
# cannot establish an SSE stream because no session exists yet.
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 pre-session GET now leaves an unused stateful session behind. The manager creates and registers a transport before this handler can return 405, and the client's fallback initialize creates another one; with the default no idle timeout, each probe retains a server task and session entry indefinitely. Consider rejecting session-less GETs in the manager before provisioning a transport (or explicitly removing and closing the provisional transport).

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 670:

<comment>A pre-session GET now leaves an unused stateful session behind. The manager creates and registers a transport before this handler can return 405, and the client's fallback initialize creates another one; with the default no idle timeout, each probe retains a server task and session entry indefinitely. Consider rejecting session-less GETs in the manager before provisioning a transport (or explicitly removing and closing the provisional transport).</comment>

<file context>
@@ -656,11 +656,29 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
+        # Per MCP spec, pre-session GETs that cannot be served as SSE must return
+        # 405 Method Not Allowed. A GET without a session ID in stateful mode
+        # cannot establish an SSE stream because no session exists yet.
+        if self.mcp_session_id and not self._get_session_id(request):
+            headers = {
+                "Allow": "GET, POST, DELETE",
</file context>

headers = {
"Allow": "GET, POST, DELETE",
}
response = self._create_error_response(
"Method Not Allowed: GET requires an established session",
HTTPStatus.METHOD_NOT_ALLOWED,
headers=headers,
)
await response(request.scope, request.receive, send)
return

# Validate Accept header - must include text/event-stream
_, has_sse = check_accept_headers(request)

Expand Down
10 changes: 10 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3094,6 +3094,16 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="Only observable over HTTP: 405 is an HTTP status code.",
),
"hosting:http:pre-session-get-405": Requirement(
source=f"{SPEC_BASE_URL}/basic/transports#receiving-messages-from-the-server",
behavior=(
"A GET without a session ID in stateful mode returns 405 Method Not Allowed. "
"Per MCP 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.'"
),
transports=("streamable-http",),
note="Only observable over HTTP: 405 is an HTTP status code. Fixes issue #3102.",
),
"hosting:http:no-broadcast": Requirement(
source=f"{SPEC_BASE_URL}/basic/transports#multiple-connections",
behavior=(
Expand Down
48 changes: 48 additions & 0 deletions tests/interaction/transports/test_hosting_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,54 @@ async def test_unsupported_http_methods_return_405() -> None:
assert (patch.status_code, patch.headers.get("allow")) == snapshot((405, "GET, POST, DELETE"))


@requirement("hosting:http:pre-session-get-405")
async def test_pre_session_get_returns_405() -> None:
"""A GET without a session ID in stateful mode returns 405 Method Not Allowed.

Per MCP 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." A pre-session GET cannot
establish an SSE stream, so 405 is the spec-mandated response.

See: https://github.com/modelcontextprotocol/python-sdk/issues/3102
"""
async with mounted_app(_server()) as (http, _):
# Test with various Accept headers - all should return 405 for pre-session GET
# Accept: */* (wildcard)
response_wildcard = await http.get("/mcp", headers={"accept": "*/*", "mcp-protocol-version": "2025-11-25"})
# Accept: application/json (no SSE)
response_json = await http.get(
"/mcp", headers={"accept": "application/json", "mcp-protocol-version": "2025-11-25"}
)
# No Accept header at all
response_no_accept = await http.get("/mcp", headers={"mcp-protocol-version": "2025-11-25"})
# Accept: text/event-stream (correct, but still no session)
response_sse = await http.get(
"/mcp", headers={"accept": "text/event-stream", "mcp-protocol-version": "2025-11-25"}
)

# All pre-session GETs must return 405 with Allow header listing supported methods
assert (response_wildcard.status_code, response_wildcard.headers.get("allow")) == snapshot(
(405, "GET, POST, DELETE")
)
assert "Method Not Allowed" in response_wildcard.json()["error"]["message"]
assert response_wildcard.json()["error"]["code"] == -32600 # INVALID_REQUEST

assert (response_json.status_code, response_json.headers.get("allow")) == snapshot(
(405, "GET, POST, DELETE")
)
assert "Method Not Allowed" in response_json.json()["error"]["message"]

assert (response_no_accept.status_code, response_no_accept.headers.get("allow")) == snapshot(
(405, "GET, POST, DELETE")
)
assert "Method Not Allowed" in response_no_accept.json()["error"]["message"]

assert (response_sse.status_code, response_sse.headers.get("allow")) == snapshot(
(405, "GET, POST, DELETE")
)
assert "Method Not Allowed" in response_sse.json()["error"]["message"]


@requirement("hosting:http:accept-406")
async def test_missing_accept_media_types_return_406() -> None:
"""A POST whose Accept header lacks both required types, or a GET lacking text/event-stream, returns 406."""
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 but fails because GET requires an established session.
# Per MCP spec, pre-session GETs return 405 Method Not Allowed (issue #3102).
assert response.status_code == 405
body = response.json()
assert "Missing session ID" in body["error"]["message"]
assert "Method Not Allowed" in body["error"]["message"]
Loading