Repository navigation
streamable_http (legacy mode): early response.aclose() discards a TCP connection per JSON-RPC exchange — deterministic reproduction for #2707 #3281
Description
Activity
- addedv2Affects the v2 line (2.x on main)Affects the v2 line (2.x on main)bugSomething isn't workingSomething isn't workingP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable feature
on Aug 11, 2026 Hi, I noticed that PR #3284 related to this issue is closed, while the issue itself is still open. Is there still work needed here? If so, I'd be happy to take it up. Could this issue be assigned to me?
I can confirm this on the latest 2.x (2.3.0, which is also current
mainat 2118f14) and on the latest 1.x (1.30.0, also 1.29.0). There is a second early close that the open fix in #2712 does not cover: the202 Acceptedbranch of_handle_post_request.The 202 path. For every notification POST (
notifications/initialized,notifications/cancelled, ...) the server answers202 Acceptedwith an empty body. The client returns from inside theasync with stream_within_origin(...)block without reading that body:- 2.3.0:
streamable_http.pyL377-L388 - 1.30.0:
streamable_http.pyL368-L370
The body is empty, but h11 only reaches
DONEfor the response after the client reads the end of the message. httpcore's_response_closedreturns the connection to the pool only when both sides areDONE, otherwise it closes it. So every notification costs one TCP (plus TLS) connection, the same way the SSEresponse.aclose()(2.3.0 L500-L502, 1.30.0 L449-L451) does for each request. Thehttpx2switch onmaindoes not change this.Measurement. The script below runs initialize,
notifications/initialized,tools/listand onetools/call(plus the DELETE on exit) against the SDK's own server on 127.0.0.1, in legacy mode. It identifies each connection by thenetwork_streamobject inresponse.extensions, and marks a connection as dropped when no later request runs on it. A test transport simulates each fix from outside the SDK:drain-202reads the 202 body,drain-ssereads other bodies to EOF before close (what #2712 does),drain-alldoes both.run TCP connections (total) dropped after 202 dropped after SSE stock 6 1 3 drain-202 only 5 0 3 drain-sse only (#2712) 3 1 0 drain-all 2 (1 for all POSTs and DELETE, 1 for the GET stream) 0 0 The numbers are the same on 2.3.0,
mainand 1.30.0. So #2712 alone still leaves one discarded connection per notification. The closed #3284 did drain the 202 response too.Output on 2.3.0:
$ uv run --no-project --python 3.12 --with 'mcp==2.3.0' python repro.py mcp 2.3.0, httpx2 2.13.1 [stock] initialize 200 conn #1 DROPPED notifications/initialized 202 conn #2 DROPPED GET 200 conn #3 tools/list 200 conn #4 DROPPED tools/call 200 conn #5 DROPPED DELETE 200 conn #6 => 6 TCP connections in total (5 for POST/DELETE); dropped after a 202: 1, dropped after a 200 SSE response: 3 [drain-202] initialize 200 conn #1 DROPPED notifications/initialized 202 conn #2 kept GET 200 conn #3 tools/list 200 conn #2 DROPPED tools/call 200 conn #4 DROPPED DELETE 200 conn #5 => 5 TCP connections in total (4 for POST/DELETE); dropped after a 202: 0, dropped after a 200 SSE response: 3 [drain-sse] initialize 200 conn #1 kept notifications/initialized 202 conn #1 DROPPED GET 200 conn #2 tools/list 200 conn #3 kept tools/call 200 conn #3 kept DELETE 200 conn #3 => 3 TCP connections in total (2 for POST/DELETE); dropped after a 202: 1, dropped after a 200 SSE response: 0 [drain-all] initialize 200 conn #1 kept notifications/initialized 202 conn #1 kept GET 200 conn #2 tools/list 200 conn #1 kept tools/call 200 conn #1 kept DELETE 200 conn #1 => 2 TCP connections in total (1 for POST/DELETE); dropped after a 202: 0, dropped after a 200 SSE response: 01.30.0 (
uv run --no-project --python 3.12 --with 'mcp==1.30.0' python repro.py) andmainat 2118f14 print the same counts in every run (only the position of the GET line indrain-alldiffers on 1.30.0).repro.py
"""Count TCP connections the streamable HTTP client opens for one short session. Session: initialize, notifications/initialized, tools/list, tools/call, then the DELETE on exit. The server is the SDK's own (FastMCP on 1.x, MCPServer on 2.x), served by uvicorn on 127.0.0.1. Connection identity is the `network_stream` object httpcore attaches to each response (`response.extensions["network_stream"]`). The wrapper keeps a reference to every stream object, so `is` comparison cannot be fooled by id() reuse. A request's connection counts as "dropped" when no later request in the session runs on it. The long-lived GET stream is excluded from the drop counts. Four client runs against the same server: stock the SDK as released drain-202 the test transport reads the (empty) body of every 202 response before handing it to the SDK, i.e. a fix for the 202 path only drain-sse the test transport reads every non-202 response body to EOF before close, i.e. a fix for the SSE early aclose() only (what PR #2712 does) drain-all both of the above Run: uv run --with 'mcp==2.3.0' python repro.py uv run --with 'mcp==1.30.0' python repro.py """ import asyncio import json import logging import threading import time from importlib.metadata import version import uvicorn try: # 2.x import httpx2 as httpx from mcp.client.client import Client from mcp.server.mcpserver import MCPServer as ServerCls V2 = True except ImportError: # 1.x import httpx from mcp import ClientSession from mcp.server.fastmcp import FastMCP as ServerCls V2 = False from mcp.client.streamable_http import streamable_http_client PORT = 8973 URL = f"http://127.0.0.1:{PORT}/mcp" server = ServerCls(name="repro") @server.tool() def echo(text: str) -> str: """Echo the text back.""" return text class DrainOnClose(httpx.AsyncByteStream): """Reads the rest of the body before closing, so the pool can keep the connection.""" def __init__(self, inner): self._inner = inner self._it = inner.__aiter__() async def __aiter__(self): async for chunk in self._it: yield chunk async def aclose(self): try: async for _ in self._it: pass finally: await self._inner.aclose() class CountingTransport(httpx.AsyncBaseTransport): def __init__(self, mode: str): self.inner = httpx.AsyncHTTPTransport() self.mode = mode self.log = [] # (label, status, network_stream) async def handle_async_request(self, request): resp = await self.inner.handle_async_request(request) label = request.method if request.method == "POST": label = json.loads(request.content)["method"] self.log.append((label, resp.status_code, resp.extensions["network_stream"])) if self.mode in ("drain-202", "drain-all") and resp.status_code == 202: await resp.aread() if self.mode in ("drain-sse", "drain-all") and request.method != "GET" and resp.status_code != 202: resp.stream = DrainOnClose(resp.stream) return resp async def aclose(self): await self.inner.aclose() async def session(mode: str) -> None: t = CountingTransport(mode) async with httpx.AsyncClient(transport=t, timeout=30) as hc: if V2: async with Client(streamable_http_client(URL, http_client=hc), mode="legacy") as c: await c.list_tools() await c.call_tool("echo", {"text": "hi"}) else: async with streamable_http_client(URL, http_client=hc) as (r, w, _): async with ClientSession(r, w) as s: await s.initialize() await s.list_tools() await s.call_tool("echo", {"text": "hi"}) streams = [] for _, _, ns in t.log: if not any(ns is s for s in streams): streams.append(ns) conn_no = lambda ns: next(i for i, s in enumerate(streams) if s is ns) + 1 # noqa: E731 dropped = {202: 0, 200: 0} print(f"[{mode}]") for i, (label, status, ns) in enumerate(t.log): later = [n for _, _, n in t.log[i + 1 :]] if label == "GET" or i == len(t.log) - 1: note = "" elif any(n is ns for n in later): note = "kept" else: note = "DROPPED" dropped[status] = dropped.get(status, 0) + 1 print(f" {label:<28} {status} conn #{conn_no(ns)} {note}") posts = [ns for label, _, ns in t.log if label != "GET"] distinct_posts = len({id(n) for n in posts}) print( f" => {len(streams)} TCP connections in total ({distinct_posts} for POST/DELETE); " f"dropped after a 202: {dropped[202]}, dropped after a 200 SSE response: {dropped[200]}" ) def main() -> None: logging.disable(logging.INFO) srv = uvicorn.Server(uvicorn.Config(server.streamable_http_app(), host="127.0.0.1", port=PORT, log_level="error")) threading.Thread(target=srv.run, daemon=True).start() while not srv.started: time.sleep(0.05) print(f"mcp {version('mcp')}, {httpx.__name__} {httpx.__version__}") for mode in ("stock", "drain-202", "drain-sse", "drain-all"): asyncio.run(session(mode)) srv.should_exit = True if __name__ == "__main__": main()
Impact. On mcp 1.29.0 against a local FastMCP server over HTTPS, one tool load on a fresh session opened 5 connections. With every POST response read to the end, the same load opened 2 (one pooled connection for all POSTs, one for the GET stream). Each extra connection is a TCP and TLS handshake on the critical path of the session start. Our production worker runs many sessions in one process, so the handshakes add up there.
Possible fix. Read the 202 body before returning (
await response.aread(); it is empty), alongside the SSE drain from #2712. A connection-count assertion like the one above is deterministic on localhost, so it can guard both paths in CI.I used an AI assistant to trace this and draft the report; I verified the reproduction myself.
- 2.3.0:
Summary
In
mcp2.0.0, the streamable-HTTP client in legacy mode uses a fresh TCP connection for every JSON-RPC exchange. The cause is the early-close pattern previously discussed in #2707 / PR #2712:_handle_sse_responsecallsawait response.aclose()as soon as the JSON-RPC reply event arrives, and httpx cannot return an undrained streaming response's connection to the pool, so the connection is discarded instead of reused.#2707 was closed with "no reproduction was provided." Below is a deterministic, self-contained reproduction against the SDK's own server (macOS arm64 / Python 3.12 / mcp 2.0.0). On this platform the symptom is connection-per-exchange (rather than the ~260 ms poisoned-reuse stall the original reporter saw on Windows), which is why it is easy to miss on loopback: a fresh localhost connection is nearly free. On real networks each discarded connection costs an extra TCP (+TLS) round trip per exchange.
Reproduction
Tracks connection identity via
response.extensions["network_stream"]— two requests sharing a TCP connection see the same stream object.Output on mcp 2.0.0 (Python 3.12.13, macOS arm64):
The raw-httpx control (same three legacy JSON-RPC exchanges, bodies fully read) reuses one connection against the same server, so this is client-side behavior, not the server or the pool.
Mechanism
src/mcp/client/streamable_http.py,_handle_sse_response: onis_completethe client doesawait response.aclose()while the SSE body is undrained. Modern-mode (2026-07-28 era) responses are plainapplication/json, get fully read, and pool normally — which is whymode='auto'reuses. Every legacy-era exchange (initialize, notifications,tools/list,tools/call,DELETE) is SSE-framed or closed the same way and therefore discards its connection.Impact
Any client speaking to a legacy-era (spec ≤ 2025-11-25) server pays TCP (+TLS) connection setup per JSON-RPC exchange. We hit this while benchmarking agent-protocol handshake overhead: the three-exchange legacy handshake opens three TCP connections, adding roughly one extra RTT per exchange on real links (more with TLS). PR #2712's drain-to-EOF approach would resolve it; the connection-count assertion above is CI-stable (unlike a latency assertion), e.g. "N serial exchanges over one
httpx.AsyncClientuse 1 connection."Happy to provide more measurements if useful.