Skip to content

Commit 0ebf49d

Browse files
committed
Answer HEAD on the SSE endpoint with GET's headers and no body
1 parent 4b82769 commit 0ebf49d

4 files changed

Lines changed: 23 additions & 8 deletions

File tree

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,8 +1173,12 @@ def sse_app(
11731173

11741174
async def sse_endpoint(request: Request) -> Response:
11751175
if request.method == "HEAD":
1176-
# The SSE stream never ends, so a HEAD answered with it would never complete.
1177-
return Response(status_code=405, headers={"Allow": "GET"})
1176+
# GET's headers without its never-ending stream; a Content-Length would claim an empty one.
1177+
response = Response(
1178+
media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
1179+
)
1180+
del response.headers["content-length"]
1181+
return response
11781182
async with sse.connect_sse( # pragma: no cover
11791183
request.scope,
11801184
request.receive,

‎src/mcp/server/sse.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
1616
# Define handler functions
1717
async def handle_sse(request):
18-
# Starlette also routes HEAD here; the stream never ends, so a HEAD would never complete
18+
# Starlette also routes HEAD here: answer with GET's headers, not its never-ending stream
1919
if request.method == "HEAD":
20-
return Response(status_code=405, headers={"Allow": "GET"})
20+
response = Response(media_type="text/event-stream", headers={"Cache-Control": "no-store"})
21+
del response.headers["content-length"]
22+
return response
2123
async with sse.connect_sse(
2224
request.scope, request.receive, request._send
2325
) as streams:

‎tests/interaction/_requirements.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2708,8 +2708,8 @@ def __post_init__(self) -> None:
27082708
"transport:sse:head": Requirement(
27092709
source="issue:#3736",
27102710
behavior=(
2711-
"A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered 405 with `Allow: GET` "
2712-
"without opening a session or a stream, so the connection stays usable."
2711+
"A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered at once with the "
2712+
"headers a GET would get and no body or Content-Length, without opening a session or a stream."
27132713
),
27142714
transports=("sse",),
27152715
note="Only observable over the legacy SSE transport.",

‎tests/interaction/transports/test_sse.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,21 @@ async def test_post_for_an_unknown_session_is_rejected() -> None:
9393

9494

9595
@requirement("transport:sse:head")
96-
async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() -> None:
96+
async def test_head_on_the_sse_endpoint_is_answered_with_headers_only() -> None:
9797
"""`MCPServer.sse_app()` answers HEAD at once instead of streaming forever."""
9898
app = MCPServer("legacy").sse_app(
9999
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
100100
)
101101
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
102102
with anyio.fail_after(5):
103103
response = await http.head("/sse")
104-
assert (response.status_code, response.headers["allow"]) == snapshot((405, "GET"))
104+
assert (response.status_code, dict(response.headers)) == snapshot(
105+
(
106+
200,
107+
{
108+
"cache-control": "no-store",
109+
"x-accel-buffering": "no",
110+
"content-type": "text/event-stream; charset=utf-8",
111+
},
112+
)
113+
)

0 commit comments

Comments
 (0)