Skip to content

SSE transport: HEAD on the SSE endpoint never completes, hanging keep-alive connections and leaking the handler #3736

Open

Description

@stancel

Initial Checks

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:

  1. 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.
  2. 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:

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
1

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.

Activity

  1. stancel commented on Oct 21, 2026

    @stancel
    Author

    Companion issue for the generic part in sse-starlette (EventSourceResponse streams forever on HEAD): sysid/sse-starlette#218.

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

    @ameymhadgut

    Hi, I will take this one.

  4. added 2 commits that reference this issue on Oct 23, 2026
    a6189f0
    b68722b
  5. Jihaochang1 commented on Oct 23, 2026

    @Jihaochang1

    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.

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