Skip to content

SSE transport: invalid Host/Origin header on /sse crashes entire server process, not just that request #3661

Description

@Stonica

Disclosure

I used an AI coding assistant (Claude) to help diagnose this while debugging a crash in a downstream project (ida-pro-mcp's idalib-mcp, SSE transport). I hit the crash myself, read the traceback and the relevant source myself, and I'm filing this because it's a real, reproducible bug affecting my own setup.

What happened

Running an SSE server built with FastMCP/sse_app() (older release pinned to mcp<2, but I confirmed the same code path still exists on current main at src/mcp/server/sse.py:137-148 and src/mcp/server/mcpserver/server.py:1175-1182), a single HTTP request to the /sse endpoint with an invalid Host header (DNS-rebinding protection rejecting it) crashes the entire server process, not just that one request. After the crash, the port is no longer listening and the process is gone — every other connected client is dropped too.

Root cause

In connect_sse() (src/mcp/server/sse.py):

error_response = await self._security.validate_request(request, is_post=False)
if error_response:
    await error_response(scope, receive, send)
    raise ValueError("Request validation failed")

It sends a complete HTTP response (e.g. 421 Invalid Host header) via error_response(...), and then also raises ValueError. Since connect_sse is an @asynccontextmanager-wrapped async generator used as async with sse.connect_sse(...) as streams: in handle_sse (src/mcp/server/mcpserver/server.py), this exception propagates out of handle_sse after a complete ASGI response has already been sent for that scope. handle_sse itself has no try/except around the async with block, so the exception escapes the ASGI app entirely. In my reproduction this took down the whole uvicorn process (confirmed via process list on the host — the server's PID disappeared after the exception), rather than uvicorn/Starlette gracefully 500-ing just that one request.

I could not fully pin down why this specific raise-after-send pattern destabilizes the whole process rather than just the one connection (possibly an ASGI double-response/protocol-level issue specific to how the SSE app is mounted as a raw ASGI callable rather than through Starlette's normal exception-handling middleware) — but the fix doesn't require answering that: the extra raise is never needed for the client to get its error response, since error_response(...) has already fully served it.

Reproduction

  1. Run any SSE-transport MCP server via FastMCP's sse_app()/run(transport="sse") with DNS-rebinding protection enabled (the default when host is 127.0.0.1/localhost/::1).
  2. Send a request to /sse with a Host header that isn't in the allowed list, e.g.:
    curl -H "Host: not-allowed-host:1234" http://127.0.0.1:<port>/sse
    
  3. Observe: the client correctly gets rejected (421/403), but the server process itself exits/crashes. A second request to any endpoint, even a valid one, fails to connect at all because the process is gone.

Expected behavior

An invalid/rejected request should result in that one request being refused (as it already correctly is, via error_response), without affecting the server process or any other connection. At minimum, handle_sse should catch and log any exception from the async with sse.connect_sse(...) block rather than letting it propagate and (in my case) kill the whole server.

Environment

  • mcp installed via ida-pro-mcp's pinned mcp<2 constraint (observed on an older 1.x release); confirmed the identical raise ValueError(...) after await error_response(...) pattern is still present on current main.
  • Windows 11, Python 3.14, uvicorn ASGI server, SSE transport (not streamable-http).

Impact

Any client (or stray LAN scanner, misconfigured proxy, etc.) that sends one request with a mismatched Host/Origin header can take down the entire MCP server for all other connected clients — essentially a one-request DoS against any SSE-transport server that has DNS-rebinding protection enabled (which is the default for a localhost-bound server).

Happy to share the full traceback/logs from my reproduction if useful. I have a draft fix (remove the redundant raise after the response is already sent, and/or wrap handle_sse's body in try/except with logger.exception) but per CONTRIBUTING.md I understand I should wait to be assigned before opening a PR.

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 9, 2026
  2. KaiyiQuan commented on Oct 9, 2026

    @KaiyiQuan

    I've opened PR #3663 (branch fix/3661-sse-validation-crash) implementing the catch-and-log approach suggested in the report: handle_sse now swallows the ValueError that connect_sse raises after sending the rejection response (421/403), so one malformed request can no longer crash the server process, while the connect_sse send-then-raise contract (asserted by existing transport tests) is kept. Could you assign this issue to me?

    Verification: new regression test drives MCPServer.sse_app() with a disallowed Host — 421 returned and the app keeps serving (subsequent 404). SSE security suite 32/32, tests/server 1367 passed.

  3. cc-bb-aa commented on Oct 9, 2026

    @cc-bb-aa

    A header parsing error taking down the whole process turns a validation bug into a DoS. Per request error isolation plus early Host and Origin validation against an allowlist fixes both. The SDK should treat transport headers as untrusted input, same as message payloads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions