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
- 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).
- 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
- 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.
Disclosure
I used an AI coding assistant (Claude) to help diagnose this while debugging a crash in a downstream project (
ida-pro-mcp'sidalib-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 tomcp<2, but I confirmed the same code path still exists on currentmainatsrc/mcp/server/sse.py:137-148andsrc/mcp/server/mcpserver/server.py:1175-1182), a single HTTP request to the/sseendpoint with an invalidHostheader (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):It sends a complete HTTP response (e.g. 421 Invalid Host header) via
error_response(...), and then also raisesValueError. Sinceconnect_sseis an@asynccontextmanager-wrapped async generator used asasync with sse.connect_sse(...) as streams:inhandle_sse(src/mcp/server/mcpserver/server.py), this exception propagates out ofhandle_sseafter a complete ASGI response has already been sent for that scope.handle_sseitself has notry/exceptaround theasync withblock, so the exception escapes the ASGI app entirely. In my reproduction this took down the wholeuvicornprocess (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
raiseis never needed for the client to get its error response, sinceerror_response(...)has already fully served it.Reproduction
FastMCP'ssse_app()/run(transport="sse")with DNS-rebinding protection enabled (the default whenhostis127.0.0.1/localhost/::1)./ssewith aHostheader that isn't in the allowed list, e.g.: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_sseshould catch and log any exception from theasync with sse.connect_sse(...)block rather than letting it propagate and (in my case) kill the whole server.Environment
mcpinstalled viaida-pro-mcp's pinnedmcp<2constraint (observed on an older 1.x release); confirmed the identicalraise ValueError(...)afterawait error_response(...)pattern is still present on currentmain.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
raiseafter the response is already sent, and/or wraphandle_sse's body in try/except withlogger.exception) but perCONTRIBUTING.mdI understand I should wait to be assigned before opening a PR.