Repository navigation
SSE transport: HEAD on the SSE endpoint never completes, hanging keep-alive connections and leaking the handler #3736
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
v2.x
Description
MCPServer.sse_app() registers the SSE endpoint with methods=["GET"], and Starlette adds HEAD to every GET route. So HEAD /sse runs the full SSE handler: connect_sse() opens a session and EventSourceResponse starts streaming. uvicorn sends the headers and discards the body, but the response never completes, because the stream never ends.
This has two consequences:
- The connection is stuck for good. A client or reverse proxy that reuses the keep-alive connection after the HEAD hangs on its next request until its own timeout. nginx does this: it returns a HEAD's upstream connection to its keepalive pool as soon as the headers arrive.
- The handler and the socket leak. If a follow-up request is waiting on that connection, uvicorn pauses reading and never sees the client close. The SSE handler, its session and the socket (in CLOSE_WAIT) then stay alive until the process exits. A lone HEAD whose client closes straight away is cleaned up; the leak needs the follow-up request, which is what a keep-alive proxy sends.
How we hit it: LibreChat (v0.8.7) sends HEAD <url> to every MCP server at startup, as part of its OAuth detection. Our SSE servers sit behind an APISIX (nginx) gateway. After each LibreChat restart, random requests to these servers hung without an upstream response. One server, whose request timeout was longer than its init timeout, stayed at 0 tools. After three days behind the gateway, one Python SSE server held 99 CLOSE_WAIT sockets. We have worked around it for now: the gateway sends Connection: close upstream on HEAD, and LibreChat skips the probe.
Expected: HEAD on the SSE endpoint returns the headers and completes (or answers 405), without opening a session or starting a stream.
Cause, on current releases:
- mcp 2.3.0:
sse_app()routes the SSE endpoint withmethods=["GET"], and so does theSseServerTransportdocstring example. Starlette 1.9.0 then adds HEAD (starlette/routing.py:if "GET" in self.methods: self.methods.add("HEAD")). - sse-starlette 3.5.0:
EventSourceResponsehas no HEAD handling, so it streams forever. Starlette'sStreamingResponsebehaves the same way; onlyFileResponsesends headers alone for HEAD. - uvicorn 0.54.0 (h11): it discards body bytes for HEAD but still waits for
more_body=False. It also pauses reading once a pipelined request arrives, sohttp.disconnectnever comes.
Suggested fix (tested locally): answer HEAD in the SSE endpoint before connect_sse(). For example, in sse_app()'s handle_sse:
if scope["method"] == "HEAD":
return Response(media_type="text/event-stream", headers={"Cache-Control": "no-store"})A 405 with Allow: GET would work as well. The SseServerTransport docstring example needs the same change, since downstream servers copy it.
I put that guard in front of an unmodified sse_app(). HEAD then returns 200 with headers only, the same connection serves a request after 1 s idle, nothing is left in CLOSE_WAIT, nothing is logged as an error, and GET /sse still streams.
Fixing only EventSourceResponse (headers and an empty final body on HEAD) is not enough for the SDK. handle_sse then sends its trailing return Response() on a connection that is still open. uvicorn raises RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed. and closes the connection. On GET this never shows, because by then the client has gone.
Related: #1269 and #2191 deal with HEAD on the Streamable HTTP transport (a 405, and rejecting it early). This issue is the SSE-transport counterpart.
Example Code
# repro.py (pip install "mcp==2.3.0" uvicorn)
import uvicorn
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("head-repro")
@mcp.tool()
def echo(text: str) -> str:
return text
uvicorn.run(mcp.sse_app(), host="127.0.0.1", port=8000)# HEAD, then a second request on the same connection (curl reuses it with --next)
$ curl -s -o /dev/null -I -m 5 -w 'HEAD=%{http_code}\n' http://127.0.0.1:8000/sse \
--next -s -o /dev/null -m 5 -w 'next=%{http_code} new_connections=%{num_connects} t=%{time_total}s\n' http://127.0.0.1:8000/nope
HEAD=200
next=000 new_connections=0 t=5.002893s # expected: 404 at once
# After curl has gone, the server still holds the socket: one per repeat, never released
$ ss -tan '( sport = :8000 )' | grep -c CLOSE-WAIT
1A plain Starlette app that returns sse_starlette.EventSourceResponse (or starlette.responses.StreamingResponse) from a GET route shows the same hang and leak, without the SDK.
Python & MCP Python SDK
Python 3.13.0
mcp 2.3.0, sse-starlette 3.5.0, starlette 1.9.0, uvicorn 0.54.0 (h11 0.16.0)
Also seen in production on mcp 1.28.1.
Activity
stancel commentedon Oct 21, 2026
Companion issue for the generic part in sse-starlette (
EventSourceResponsestreams forever on HEAD): sysid/sse-starlette#218.ameymhadgut commentedon Oct 21, 2026
Hi, I will take this one.
fix: handle HEAD on SSE endpoint to prevent connection leaks (fixes m…
fix: handle HEAD on SSE endpoint to prevent connection leaks (fixes m…
Jihaochang1 commentedon Oct 23, 2026
I've submitted a fix for this issue in PR #3744 (recently closed by bot due to contributor policy). The fix adds an early return in
handle_sse()for HEAD requests to prevent connection leaks.Since the PR was auto-closed, I've reopened it. The changes are minimal:
src/fastmcp/server/sse.py: Added HEAD request guard beforeconnect_sse()src/fastmcp/server/http.py: Added same guardThis is a straightforward fix that should not break any existing functionality.