SSE transport: HEAD on the SSE endpoint never completes, hanging keep-alive connections and leaking the handler · Issue #3736 · modelcontextprotocol/python-sdk · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SSE transport: HEAD on the SSE endpoint never completes, hanging keep-alive connections and leaking the handler #3736
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 with methods=["GET"], and so does the SseServerTransport docstring 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:EventSourceResponse has no HEAD handling, so it streams forever. Starlette's StreamingResponse behaves the same way; only FileResponse sends 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, so http.disconnect never comes.
Suggested fix (tested locally): answer HEAD in the SSE endpoint before connect_sse(). For example, in sse_app()'s handle_sse:
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.
# 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/nopeHEAD=200next=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-WAIT1
A 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.
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 before connect_sse()
src/fastmcp/server/http.py: Added same guard
This is a straightforward fix that should not break any existing functionality.
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.