Skip to content

Commit db4c181

Browse files
author
KaiyiQuan
committed
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/).
1 parent 91941ed commit db4c181

3 files changed

Lines changed: 43 additions & 4 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,10 +1175,20 @@ def sse_app(
11751175
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
11761176
# Add client ID from auth context into request context if available
11771177

1178-
async with sse.connect_sse(scope, receive, send) as streams:
1179-
await self._lowlevel_server.run(
1180-
streams[0], streams[1], self._lowlevel_server.create_initialization_options()
1181-
)
1178+
try:
1179+
async with sse.connect_sse(scope, receive, send) as streams:
1180+
await self._lowlevel_server.run(
1181+
streams[0], streams[1], self._lowlevel_server.create_initialization_options()
1182+
)
1183+
except ValueError as exc:
1184+
# connect_sse rejects a request failing the transport-security
1185+
# checks (e.g. a disallowed Host/Origin) by sending the error
1186+
# response itself and then raising ValueError. Without a handler
1187+
# here the exception escapes the ASGI callable and crashes the
1188+
# whole server process, so swallow it: the rejection response
1189+
# (421/403) has already been sent to this one client.
1190+
logger.debug(f"SSE connection rejected during validation: {exc}")
1191+
return
11821192
return Response()
11831193

11841194
# Create routes

‎src/mcp/server/sse.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send):
144144
request = Request(scope, receive)
145145
error_response = await self._security.validate_request(request, is_post=False)
146146
if error_response:
147+
# The rejection response (e.g. 421 invalid Host) is sent to the
148+
# client here, then ValueError is raised to signal the caller that
149+
# no SSE session was established. Callers driving the ASGI
150+
# application (FastMCP.sse_app's handle_sse) must catch this to
151+
# avoid crashing the server process on a single bad request.
147152
await error_response(scope, receive, send)
148153
raise ValueError("Request validation failed")
149154

‎tests/server/test_sse_security.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,27 @@ async def _no_receive() -> Message:
551551

552552
async def _no_send(message: Message) -> None:
553553
raise NotImplementedError
554+
555+
556+
@pytest.mark.anyio
557+
async def test_fastmcp_handle_sse_does_not_crash_on_invalid_host() -> None:
558+
"""A disallowed Host on /sse must reject that request (421) without
559+
crashing the server process: connect_sse sends the rejection response and
560+
raises ValueError, and FastMCP's handle_sse swallows it (see #3661)."""
561+
from mcp.server.mcpserver import MCPServer
562+
563+
mcp = MCPServer("sse-crash-guard")
564+
app = mcp.sse_app(
565+
message_path="/messages/",
566+
transport_security=TransportSecuritySettings(
567+
enable_dns_rebinding_protection=True, allowed_hosts=["allowed.example.com"]
568+
),
569+
)
570+
transport = StreamingASGITransport(app, cancel_on_close=False)
571+
572+
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client:
573+
# The GET would otherwise hang until disconnect; the rejection path
574+
# returns before any session is created, so a plain GET suffices.
575+
response = await client.get("/sse", headers={"Host": "evil.com"})
576+
assert response.status_code == 421
577+
assert response.text == "Invalid Host header"

0 commit comments

Comments
 (0)