From db4c181b531c6695e3972ce82cb24e789ef972bc Mon Sep 17 00:00:00 2001 From: KaiyiQuan Date: Fri, 9 Oct 2026 18:09:07 +0800 Subject: [PATCH 1/4] fix(server): do not crash the SSE server process on an invalid Host/Origin When a request to /sse fails the transport-security checks (DNS-rebinding protection: disallowed Host -> 421, disallowed Origin -> 403), connect_sse sends the rejection response and then raises ValueError to signal the caller that no SSE session was established. FastMCP.sse_app()'s handle_sse had no handler for it, so the exception escaped the ASGI callable and uvicorn crashed the whole server process -- one malformed request killed every client (reported in #3661 via ida-pro-mcp's idalib-mcp). Catch ValueError in handle_sse: the rejection response has already been sent to that one client, so just return. Also document connect_sse's send-then-raise contract so future callers know to handle it. Adds a regression test driving MCPServer.sse_app() with a disallowed Host through the in-process ASGI bridge: 421 returned and no exception escapes. 32 server/security tests pass (1367 in tests/server/). --- src/mcp/server/mcpserver/server.py | 18 ++++++++++++++---- src/mcp/server/sse.py | 5 +++++ tests/server/test_sse_security.py | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) 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..5ddad9db50 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -551,3 +551,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" From b495799b5a174360235ffd15e9d0bdd39bc7962d Mon Sep 17 00:00:00 2001 From: KaiyiQuan Date: Fri, 9 Oct 2026 18:12:03 +0800 Subject: [PATCH 2/4] test(server): assert the SSE server survives a rejected request --- tests/server/test_sse_security.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 5ddad9db50..0cf13b2b89 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 the message endpoint is still served (the + # unknown-session 404 proves routing works rather than hanging). + response = await client.post("/messages/?session_id=12345678123456781234567812345678") + assert response.status_code == 404 + @pytest.mark.anyio async def test_sse_security_invalid_origin_header() -> None: From dc135e3026585df23a1e8ab6c7eaddc4709c5125 Mon Sep 17 00:00:00 2001 From: KaiyiQuan Date: Fri, 9 Oct 2026 18:13:20 +0800 Subject: [PATCH 3/4] test(server): assert the SSE server survives a rejected request --- tests/server/test_sse_security.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index 0cf13b2b89..dab0f9629d 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -91,7 +91,10 @@ async def test_sse_security_invalid_host_header() -> None: # The server process must stay alive after the rejected request: a # subsequent request to the message endpoint is still served (the # unknown-session 404 proves routing works rather than hanging). - response = await client.post("/messages/?session_id=12345678123456781234567812345678") + response = await client.post( + "/messages/?session_id=12345678123456781234567812345678", + headers={"Content-Type": "application/json"}, + ) assert response.status_code == 404 From c7c7727fe35df2107dc4a52928e6dcf1b2b078d8 Mon Sep 17 00:00:00 2001 From: KaiyiQuan Date: Fri, 9 Oct 2026 18:14:51 +0800 Subject: [PATCH 4/4] test(server): assert the SSE server survives a rejected request --- tests/server/test_sse_security.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py index dab0f9629d..0d07ec527c 100644 --- a/tests/server/test_sse_security.py +++ b/tests/server/test_sse_security.py @@ -89,12 +89,9 @@ async def test_sse_security_invalid_host_header() -> None: assert response.text == "Invalid Host header" # The server process must stay alive after the rejected request: a - # subsequent request to the message endpoint is still served (the - # unknown-session 404 proves routing works rather than hanging). - response = await client.post( - "/messages/?session_id=12345678123456781234567812345678", - headers={"Content-Type": "application/json"}, - ) + # 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