Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/server/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
30 changes: 30 additions & 0 deletions tests/server/test_sse_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Loading