Skip to content

Commit 4b82769

Browse files
committed
Answer HEAD on the SSE endpoint with 405 instead of opening a stream
1 parent a525a3d commit 4b82769

4 files changed

Lines changed: 40 additions & 13 deletions

File tree

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

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@
5151
from starlette.middleware.authentication import AuthenticationMiddleware
5252
from starlette.requests import Request
5353
from starlette.responses import Response
54-
from starlette.routing import Mount, Route
55-
from starlette.types import Receive, Scope, Send
54+
from starlette.routing import Mount, Route, request_response
5655

5756
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
5857
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
@@ -1172,14 +1171,19 @@ def sse_app(
11721171
message_path, security_settings=transport_security, max_request_body_size=max_request_body_size
11731172
)
11741173

1175-
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
1176-
# Add client ID from auth context into request context if available
1177-
1178-
async with sse.connect_sse(scope, receive, send) as streams:
1174+
async def sse_endpoint(request: Request) -> Response:
1175+
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"})
1178+
async with sse.connect_sse( # pragma: no cover
1179+
request.scope,
1180+
request.receive,
1181+
request._send, # type: ignore[reportPrivateUsage]
1182+
) as streams:
11791183
await self._lowlevel_server.run(
11801184
streams[0], streams[1], self._lowlevel_server.create_initialization_options()
11811185
)
1182-
return Response()
1186+
return Response() # pragma: no cover
11831187

11841188
# Create routes
11851189
routes: list[Route | Mount] = []
@@ -1237,7 +1241,9 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
12371241
routes.append(
12381242
Route(
12391243
sse_path,
1240-
endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url),
1244+
endpoint=RequireAuthMiddleware(
1245+
request_response(sse_endpoint), required_scopes, resource_metadata_url
1246+
),
12411247
methods=["GET"],
12421248
)
12431249
)
@@ -1249,11 +1255,6 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
12491255
)
12501256
else:
12511257
# Auth is disabled, no need for RequireAuthMiddleware
1252-
# Since handle_sse is an ASGI app, we need to create a compatible endpoint
1253-
async def sse_endpoint(request: Request) -> Response: # pragma: no cover
1254-
# Convert the Starlette request to ASGI parameters
1255-
return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
1256-
12571258
routes.append(
12581259
Route(
12591260
sse_path,

‎src/mcp/server/sse.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
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
19+
if request.method == "HEAD":
20+
return Response(status_code=405, headers={"Allow": "GET"})
1821
async with sse.connect_sse(
1922
request.scope, request.receive, request._send
2023
) as streams:

‎tests/interaction/_requirements.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2705,6 +2705,15 @@ def __post_init__(self) -> None:
27052705
transports=("sse",),
27062706
note="Only observable over the legacy SSE transport.",
27072707
),
2708+
"transport:sse:head": Requirement(
2709+
source="issue:#3736",
2710+
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."
2713+
),
2714+
transports=("sse",),
2715+
note="Only observable over the legacy SSE transport.",
2716+
),
27082717
"transport:stdio": Requirement(
27092718
source=f"{SPEC_BASE_URL}/basic/transports#stdio",
27102719
behavior=(

‎tests/interaction/transports/test_sse.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from mcp.client.client import Client
1919
from mcp.client.sse import sse_client
2020
from mcp.server import Server
21+
from mcp.server.mcpserver import MCPServer
22+
from mcp.server.transport_security import TransportSecuritySettings
2123
from tests.interaction._connect import BASE_URL, build_sse_app
2224
from tests.interaction._requirements import requirement
2325
from tests.interaction.transports._bridge import StreamingASGITransport
@@ -88,3 +90,15 @@ async def test_post_for_an_unknown_session_is_rejected() -> None:
8890
"/messages/", params={"session_id": uuid4().hex}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
8991
)
9092
assert (response.status_code, response.text) == snapshot((404, "Could not find session"))
93+
94+
95+
@requirement("transport:sse:head")
96+
async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() -> None:
97+
"""`MCPServer.sse_app()` answers HEAD at once instead of streaming forever."""
98+
app = MCPServer("legacy").sse_app(
99+
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
100+
)
101+
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
102+
with anyio.fail_after(5):
103+
response = await http.head("/sse")
104+
assert (response.status_code, response.headers["allow"]) == snapshot((405, "GET"))

0 commit comments

Comments
 (0)