From 4b82769e2df98619edad5d2b779de5f7697e347c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 24 Oct 2026 20:29:34 +0000 Subject: [PATCH 1/5] Answer HEAD on the SSE endpoint with 405 instead of opening a stream --- src/mcp/server/mcpserver/server.py | 27 ++++++++++++------------ src/mcp/server/sse.py | 3 +++ tests/interaction/_requirements.py | 9 ++++++++ tests/interaction/transports/test_sse.py | 14 ++++++++++++ 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7efa29108d..ba572ad33b 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -51,8 +51,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.responses import Response -from starlette.routing import Mount, Route -from starlette.types import Receive, Scope, Send +from starlette.routing import Mount, Route, request_response from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware @@ -1172,14 +1171,19 @@ def sse_app( message_path, security_settings=transport_security, max_request_body_size=max_request_body_size ) - async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover - # Add client ID from auth context into request context if available - - async with sse.connect_sse(scope, receive, send) as streams: + async def sse_endpoint(request: Request) -> Response: + if request.method == "HEAD": + # The SSE stream never ends, so a HEAD answered with it would never complete. + return Response(status_code=405, headers={"Allow": "GET"}) + async with sse.connect_sse( # pragma: no cover + request.scope, + request.receive, + request._send, # type: ignore[reportPrivateUsage] + ) as streams: await self._lowlevel_server.run( streams[0], streams[1], self._lowlevel_server.create_initialization_options() ) - return Response() + return Response() # pragma: no cover # Create routes routes: list[Route | Mount] = [] @@ -1237,7 +1241,9 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no routes.append( Route( sse_path, - endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), + endpoint=RequireAuthMiddleware( + request_response(sse_endpoint), required_scopes, resource_metadata_url + ), methods=["GET"], ) ) @@ -1249,11 +1255,6 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no ) else: # Auth is disabled, no need for RequireAuthMiddleware - # Since handle_sse is an ASGI app, we need to create a compatible endpoint - async def sse_endpoint(request: Request) -> Response: # pragma: no cover - # Convert the Starlette request to ASGI parameters - return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] - routes.append( Route( sse_path, diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index d71ef25004..dc26620d42 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -15,6 +15,9 @@ # Define handler functions async def handle_sse(request): + # Starlette also routes HEAD here; the stream never ends, so a HEAD would never complete + if request.method == "HEAD": + return Response(status_code=405, headers={"Allow": "GET"}) async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 1d9033b307..189fb66c4d 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2705,6 +2705,15 @@ def __post_init__(self) -> None: transports=("sse",), note="Only observable over the legacy SSE transport.", ), + "transport:sse:head": Requirement( + source="issue:#3736", + behavior=( + "A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered 405 with `Allow: GET` " + "without opening a session or a stream, so the connection stays usable." + ), + transports=("sse",), + note="Only observable over the legacy SSE transport.", + ), "transport:stdio": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#stdio", behavior=( diff --git a/tests/interaction/transports/test_sse.py b/tests/interaction/transports/test_sse.py index ffa5c5728e..c65e0ddb0a 100644 --- a/tests/interaction/transports/test_sse.py +++ b/tests/interaction/transports/test_sse.py @@ -18,6 +18,8 @@ from mcp.client.client import Client from mcp.client.sse import sse_client from mcp.server import Server +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings from tests.interaction._connect import BASE_URL, build_sse_app from tests.interaction._requirements import requirement from tests.interaction.transports._bridge import StreamingASGITransport @@ -88,3 +90,15 @@ async def test_post_for_an_unknown_session_is_rejected() -> None: "/messages/", params={"session_id": uuid4().hex}, json={"jsonrpc": "2.0", "method": "ping", "id": 1} ) assert (response.status_code, response.text) == snapshot((404, "Could not find session")) + + +@requirement("transport:sse:head") +async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() -> None: + """`MCPServer.sse_app()` answers HEAD at once instead of streaming forever.""" + app = MCPServer("legacy").sse_app( + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False) + ) + async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http: + with anyio.fail_after(5): + response = await http.head("/sse") + assert (response.status_code, response.headers["allow"]) == snapshot((405, "GET")) From 0ebf49d9744c502e89df58fc72d969f7073ac8ef Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Oct 2026 07:02:39 +0000 Subject: [PATCH 2/5] Answer HEAD on the SSE endpoint with GET's headers and no body --- src/mcp/server/mcpserver/server.py | 8 ++++++-- src/mcp/server/sse.py | 6 ++++-- tests/interaction/_requirements.py | 4 ++-- tests/interaction/transports/test_sse.py | 13 +++++++++++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index ba572ad33b..4996f2deb0 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1173,8 +1173,12 @@ def sse_app( async def sse_endpoint(request: Request) -> Response: if request.method == "HEAD": - # The SSE stream never ends, so a HEAD answered with it would never complete. - return Response(status_code=405, headers={"Allow": "GET"}) + # GET's headers without its never-ending stream; a Content-Length would claim an empty one. + response = Response( + media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} + ) + del response.headers["content-length"] + return response async with sse.connect_sse( # pragma: no cover request.scope, request.receive, diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index dc26620d42..242b0b9117 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -15,9 +15,11 @@ # Define handler functions async def handle_sse(request): - # Starlette also routes HEAD here; the stream never ends, so a HEAD would never complete + # Starlette also routes HEAD here: answer with GET's headers, not its never-ending stream if request.method == "HEAD": - return Response(status_code=405, headers={"Allow": "GET"}) + response = Response(media_type="text/event-stream", headers={"Cache-Control": "no-store"}) + del response.headers["content-length"] + return response async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 189fb66c4d..85fb92f709 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2708,8 +2708,8 @@ def __post_init__(self) -> None: "transport:sse:head": Requirement( source="issue:#3736", behavior=( - "A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered 405 with `Allow: GET` " - "without opening a session or a stream, so the connection stays usable." + "A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered at once with the " + "headers a GET would get and no body or Content-Length, without opening a session or a stream." ), transports=("sse",), note="Only observable over the legacy SSE transport.", diff --git a/tests/interaction/transports/test_sse.py b/tests/interaction/transports/test_sse.py index c65e0ddb0a..1ed2ef6050 100644 --- a/tests/interaction/transports/test_sse.py +++ b/tests/interaction/transports/test_sse.py @@ -93,7 +93,7 @@ async def test_post_for_an_unknown_session_is_rejected() -> None: @requirement("transport:sse:head") -async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() -> None: +async def test_head_on_the_sse_endpoint_is_answered_with_headers_only() -> None: """`MCPServer.sse_app()` answers HEAD at once instead of streaming forever.""" app = MCPServer("legacy").sse_app( transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False) @@ -101,4 +101,13 @@ async def test_head_on_the_sse_endpoint_is_answered_without_opening_a_stream() - async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http: with anyio.fail_after(5): response = await http.head("/sse") - assert (response.status_code, response.headers["allow"]) == snapshot((405, "GET")) + assert (response.status_code, dict(response.headers)) == snapshot( + ( + 200, + { + "cache-control": "no-store", + "x-accel-buffering": "no", + "content-type": "text/event-stream; charset=utf-8", + }, + ) + ) From 43f85b3cd32b637c7c176a94b3090e4d4fe4a320 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Oct 2026 07:18:52 +0000 Subject: [PATCH 3/5] Check Host and Origin before answering HEAD, and send nothing after the SSE stream --- src/mcp/server/mcpserver/server.py | 54 +++++++++++++++--------- tests/interaction/_requirements.py | 2 +- tests/interaction/transports/test_sse.py | 15 ++++++- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 4996f2deb0..b685f7b682 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -51,7 +51,8 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.responses import Response -from starlette.routing import Mount, Route, request_response +from starlette.routing import Mount, Route +from starlette.types import ASGIApp, Receive, Scope, Send from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware @@ -98,7 +99,11 @@ StreamableHTTPSessionManager, ) from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus -from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings +from mcp.server.transport_security import ( + DEFAULT_MAX_REQUEST_BODY_SIZE, + TransportSecurityMiddleware, + TransportSecuritySettings, +) from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import UriTemplate @@ -1171,23 +1176,23 @@ def sse_app( message_path, security_settings=transport_security, max_request_body_size=max_request_body_size ) - async def sse_endpoint(request: Request) -> Response: - if request.method == "HEAD": - # GET's headers without its never-ending stream; a Content-Length would claim an empty one. - response = Response( - media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} - ) - del response.headers["content-length"] - return response - async with sse.connect_sse( # pragma: no cover - request.scope, - request.receive, - request._send, # type: ignore[reportPrivateUsage] - ) as streams: + security = TransportSecurityMiddleware(transport_security) + + async def handle_sse(scope: Scope, receive: Receive, send: Send) -> None: + if scope["method"] == "HEAD": + response = await security.validate_request(Request(scope, receive)) + if response is None: + # GET's headers without its never-ending stream; a Content-Length would claim an empty one. + response = Response( + media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} + ) + del response.headers["content-length"] + await response(scope, receive, send) + return + async with sse.connect_sse(scope, receive, send) as streams: # pragma: no cover await self._lowlevel_server.run( streams[0], streams[1], self._lowlevel_server.create_initialization_options() ) - return Response() # pragma: no cover # Create routes routes: list[Route | Mount] = [] @@ -1245,9 +1250,7 @@ async def sse_endpoint(request: Request) -> Response: routes.append( Route( sse_path, - endpoint=RequireAuthMiddleware( - request_response(sse_endpoint), required_scopes, resource_metadata_url - ), + endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), methods=["GET"], ) ) @@ -1262,7 +1265,7 @@ async def sse_endpoint(request: Request) -> Response: routes.append( Route( sse_path, - endpoint=sse_endpoint, + endpoint=_ASGIEndpoint(handle_sse), methods=["GET"], ) ) @@ -1426,3 +1429,14 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st message=f"Client did not declare required extension {identifier!r}", data=data.model_dump(by_alias=True, mode="json", exclude_none=True), ) + + +class _ASGIEndpoint: + """Hands a `Route` a raw ASGI app; a plain function would be wrapped as a request/response endpoint, + which sends a second response after the SSE stream has already completed.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.app(scope, receive, send) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 85fb92f709..e62b2563e0 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2709,7 +2709,7 @@ def __post_init__(self) -> None: source="issue:#3736", behavior=( "A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered at once with the " - "headers a GET would get and no body or Content-Length, without opening a session or a stream." + "headers a GET would get, no body and no Content-Length, after the same Host/Origin checks as a GET." ), transports=("sse",), note="Only observable over the legacy SSE transport.", diff --git a/tests/interaction/transports/test_sse.py b/tests/interaction/transports/test_sse.py index 1ed2ef6050..a63f12e78a 100644 --- a/tests/interaction/transports/test_sse.py +++ b/tests/interaction/transports/test_sse.py @@ -94,14 +94,14 @@ async def test_post_for_an_unknown_session_is_rejected() -> None: @requirement("transport:sse:head") async def test_head_on_the_sse_endpoint_is_answered_with_headers_only() -> None: - """`MCPServer.sse_app()` answers HEAD at once instead of streaming forever.""" + """`MCPServer.sse_app()` answers HEAD at once with GET's headers and no body instead of streaming forever.""" app = MCPServer("legacy").sse_app( transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False) ) async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http: with anyio.fail_after(5): response = await http.head("/sse") - assert (response.status_code, dict(response.headers)) == snapshot( + assert (response.status_code, dict(response.headers), response.content) == snapshot( ( 200, { @@ -109,5 +109,16 @@ async def test_head_on_the_sse_endpoint_is_answered_with_headers_only() -> None: "x-accel-buffering": "no", "content-type": "text/event-stream; charset=utf-8", }, + b"", ) ) + + +@requirement("transport:sse:head") +async def test_head_on_the_sse_endpoint_is_subject_to_dns_rebinding_protection() -> None: + """A HEAD from a Host the transport security settings reject gets the same refusal a GET would.""" + app = MCPServer("legacy").sse_app(host="127.0.0.1") + async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url="http://evil.example") as http: + with anyio.fail_after(5): + response = await http.head("/sse") + assert response.status_code == snapshot(421) From 58bff0131326fc85d5039719bce0d690ae1d0861 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Oct 2026 07:33:08 +0000 Subject: [PATCH 4/5] Serve both SSE routes through one request/response endpoint --- src/mcp/server/mcpserver/server.py | 48 +++++++++++++----------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index b685f7b682..9d520af155 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -51,8 +51,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.responses import Response -from starlette.routing import Mount, Route -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.routing import Mount, Route, request_response from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware @@ -1178,21 +1177,25 @@ def sse_app( security = TransportSecurityMiddleware(transport_security) - async def handle_sse(scope: Scope, receive: Receive, send: Send) -> None: - if scope["method"] == "HEAD": - response = await security.validate_request(Request(scope, receive)) - if response is None: - # GET's headers without its never-ending stream; a Content-Length would claim an empty one. - response = Response( - media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} - ) - del response.headers["content-length"] - await response(scope, receive, send) - return - async with sse.connect_sse(scope, receive, send) as streams: # pragma: no cover + async def sse_endpoint(request: Request) -> Response: + if request.method == "HEAD": + if error_response := await security.validate_request(request): + return error_response + # GET's headers without its never-ending stream; a Content-Length would claim an empty one. + response = Response( + media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} + ) + del response.headers["content-length"] + return response + async with sse.connect_sse( # pragma: no cover + request.scope, + request.receive, + request._send, # type: ignore[reportPrivateUsage] + ) as streams: await self._lowlevel_server.run( streams[0], streams[1], self._lowlevel_server.create_initialization_options() ) + return Response() # pragma: no cover # Create routes routes: list[Route | Mount] = [] @@ -1250,7 +1253,9 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send) -> None: routes.append( Route( sse_path, - endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), + endpoint=RequireAuthMiddleware( + request_response(sse_endpoint), required_scopes, resource_metadata_url + ), methods=["GET"], ) ) @@ -1265,7 +1270,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send) -> None: routes.append( Route( sse_path, - endpoint=_ASGIEndpoint(handle_sse), + endpoint=sse_endpoint, methods=["GET"], ) ) @@ -1429,14 +1434,3 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st message=f"Client did not declare required extension {identifier!r}", data=data.model_dump(by_alias=True, mode="json", exclude_none=True), ) - - -class _ASGIEndpoint: - """Hands a `Route` a raw ASGI app; a plain function would be wrapped as a request/response endpoint, - which sends a second response after the SSE stream has already completed.""" - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - await self.app(scope, receive, send) From 1e77a6d4cca4bc76e28ede53a9acba73c9a7724d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Oct 2026 07:45:47 +0000 Subject: [PATCH 5/5] Add `SseServerTransport.head_response` so the transport answers HEAD --- src/mcp/server/mcpserver/server.py | 17 ++--------------- src/mcp/server/sse.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 9d520af155..5958fad8ce 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -98,11 +98,7 @@ StreamableHTTPSessionManager, ) from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus -from mcp.server.transport_security import ( - DEFAULT_MAX_REQUEST_BODY_SIZE, - TransportSecurityMiddleware, - TransportSecuritySettings, -) +from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import UriTemplate @@ -1175,18 +1171,9 @@ def sse_app( message_path, security_settings=transport_security, max_request_body_size=max_request_body_size ) - security = TransportSecurityMiddleware(transport_security) - async def sse_endpoint(request: Request) -> Response: if request.method == "HEAD": - if error_response := await security.validate_request(request): - return error_response - # GET's headers without its never-ending stream; a Content-Length would claim an empty one. - response = Response( - media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} - ) - del response.headers["content-length"] - return response + return await sse.head_response(request) async with sse.connect_sse( # pragma: no cover request.scope, request.receive, diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 242b0b9117..9d5874b580 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -15,11 +15,9 @@ # Define handler functions async def handle_sse(request): - # Starlette also routes HEAD here: answer with GET's headers, not its never-ending stream + # Starlette also routes HEAD here, and the stream would never end if request.method == "HEAD": - response = Response(media_type="text/event-stream", headers={"Cache-Control": "no-store"}) - del response.headers["content-length"] - return response + return await sse.head_response(request) async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: @@ -77,6 +75,9 @@ class SseServerTransport: 2. handle_post_message() is an ASGI application which receives incoming POST requests, which should contain client messages that link to a previously-established SSE session. + + head_response() answers a HEAD request to the SSE endpoint, which Starlette + routes to the same endpoint as GET. """ _endpoint: str @@ -139,6 +140,27 @@ def __init__( self._post_message_app = RequestBodyLimitMiddleware(self._handle_post_message, max_request_body_size) logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") + async def head_response(self, request: Request) -> Response: + """Build the response to a HEAD request on the SSE endpoint. + + An SSE stream never ends, so a HEAD served through `connect_sse()` would never complete. + This runs the same Host/Origin validation and returns the headers a GET gets, with no body. + + Args: + request: The HEAD request. + + Returns: + The validation error response, or the SSE headers with no body. + """ + if error_response := await self._security.validate_request(request, is_post=False): + return error_response + response = Response( + media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"} + ) + # A Content-Length would claim an empty stream. + del response.headers["content-length"] + return response + @asynccontextmanager async def connect_sse(self, scope: Scope, receive: Receive, send: Send): if scope["type"] != "http":