diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 99b5a56c0a..a9edbe15db 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1175,10 +1175,20 @@ def sse_app( 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: - await self._lowlevel_server.run( - streams[0], streams[1], self._lowlevel_server.create_initialization_options() - ) + try: + async with sse.connect_sse(scope, receive, send) as streams: + await self._lowlevel_server.run( + streams[0], streams[1], self._lowlevel_server.create_initialization_options() + ) + except ValueError as exc: + # connect_sse rejects a request failing the transport-security + # checks (e.g. a disallowed Host/Origin) by sending the error + # response itself and then raising ValueError. Without a handler + # here the exception escapes the ASGI callable and crashes the + # whole server process, so swallow it: the rejection response + # (421/403) has already been sent to this one client. + logger.debug(f"SSE connection rejected during validation: {exc}") + return return Response() # Create routes diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index d71ef25004..89c172df7c 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -144,6 +144,11 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send): request = Request(scope, receive) error_response = await self._security.validate_request(request, is_post=False) if error_response: + # The rejection response (e.g. 421 invalid Host) is sent to the + # client here, then ValueError is raised to signal the caller that + # no SSE session was established. Callers driving the ASGI + # application (FastMCP.sse_app's handle_sse) must catch this to + # avoid crashing the server process on a single bad request. await error_response(scope, receive, send) raise ValueError("Request validation failed") diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 7e84428600..0d07ec527c 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -88,6 +88,12 @@ async def test_sse_security_invalid_host_header() -> None: assert response.status_code == 421 assert response.text == "Invalid Host header" + # The server process must stay alive after the rejected request: a + # subsequent request to an unmatched route is still served (404), + # proving the ASGI app did not crash. + response = await client.get("/") + assert response.status_code == 404 + @pytest.mark.anyio async def test_sse_security_invalid_origin_header() -> None: @@ -551,3 +557,27 @@ async def _no_receive() -> Message: async def _no_send(message: Message) -> None: raise NotImplementedError + + +@pytest.mark.anyio +async def test_fastmcp_handle_sse_does_not_crash_on_invalid_host() -> None: + """A disallowed Host on /sse must reject that request (421) without + crashing the server process: connect_sse sends the rejection response and + raises ValueError, and FastMCP's handle_sse swallows it (see #3661).""" + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer("sse-crash-guard") + app = mcp.sse_app( + message_path="/messages/", + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, allowed_hosts=["allowed.example.com"] + ), + ) + transport = StreamingASGITransport(app, cancel_on_close=False) + + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client: + # The GET would otherwise hang until disconnect; the rejection path + # returns before any session is created, so a plain GET suffices. + response = await client.get("/sse", headers={"Host": "evil.com"}) + assert response.status_code == 421 + assert response.text == "Invalid Host header"