Repository navigation
streamable_http (legacy mode): early response.aclose() discards a TCP connection per JSON-RPC exchange — deterministic reproduction for #2707 #3281
Description
Summary
In mcp 2.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_response calls await 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.
"""Repro: mcp 2.0.0 streamable_http legacy-mode client uses a fresh TCP
connection for every JSON-RPC exchange (early response.aclose() before the
SSE body is drained; follow-up to issue #2707 / PR #2712).
Run: python repro_connection_reuse.py
Deps: pip install "mcp==2.0.0" uvicorn httpx
"""
import asyncio
import json
import threading
import time
import httpx
import uvicorn
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import MCPServer
PORT = 8971
server = MCPServer(name="repro", version="1.0.0")
@server.tool()
def echo(text: str) -> str:
"""Echo a message back verbatim."""
return text
class StreamIdTransport(httpx.AsyncBaseTransport):
"""Log the identity of the network stream used by each request.
Two requests sharing a TCP connection see the same network_stream
object; a distinct id per request means no connection reuse.
"""
def __init__(self):
self.inner = httpx.AsyncHTTPTransport()
self.log = []
async def handle_async_request(self, request):
resp = await self.inner.handle_async_request(request)
try:
method = json.loads(request.content).get("method", request.method)
except Exception:
method = request.method
self.log.append((method, id(resp.extensions.get("network_stream"))))
return resp
async def aclose(self):
await self.inner.aclose()
async def run(mode: str) -> None:
t = StreamIdTransport()
async with httpx.AsyncClient(transport=t, timeout=30) as hc:
async with Client(
streamable_http_client(f"http://127.0.0.1:{PORT}/mcp", http_client=hc),
mode=mode,
) as client:
await client.list_tools()
ids = [i for _, i in t.log]
print(
f"mode={mode!r}: {len(t.log)} requests "
f"{[m for m, _ in t.log]} -> "
f"{len(set(ids))} distinct TCP connection(s)"
)
async def raw_httpx_control() -> None:
"""Same server, same three JSON-RPC exchanges, plain httpx: reuses."""
t = StreamIdTransport()
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(transport=t, timeout=30) as hc:
r = await hc.post(
f"http://127.0.0.1:{PORT}/mcp",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "raw", "version": "1"},
},
},
headers=headers,
)
await r.aread()
h2 = dict(headers, **{"Mcp-Session-Id": r.headers.get("mcp-session-id")})
r = await hc.post(
f"http://127.0.0.1:{PORT}/mcp",
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=h2,
)
await r.aread()
r = await hc.post(
f"http://127.0.0.1:{PORT}/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
headers=h2,
)
await r.aread()
ids = [i for _, i in t.log]
print(
f"raw httpx control: {len(t.log)} requests -> "
f"{len(set(ids))} distinct TCP connection(s)"
)
def main() -> None:
config = uvicorn.Config(
server.streamable_http_app(), host="127.0.0.1", port=PORT, log_level="error"
)
srv = uvicorn.Server(config)
th = threading.Thread(target=srv.run, daemon=True)
th.start()
time.sleep(1.5)
asyncio.run(run("legacy"))
asyncio.run(run("auto"))
asyncio.run(raw_httpx_control())
srv.should_exit = True
th.join(timeout=3)
if __name__ == "__main__":
main()Output on mcp 2.0.0 (Python 3.12.13, macOS arm64):
mode='legacy': 4 requests ['initialize', 'notifications/initialized', 'tools/list', 'DELETE'] -> 4 distinct TCP connection(s)
mode='auto': 2 requests ['server/discover', 'tools/list'] -> 1 distinct TCP connection(s)
raw httpx control: 3 requests -> 1 distinct TCP connection(s)
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: on is_complete the client does await response.aclose() while the SSE body is undrained. Modern-mode (2026-07-28 era) responses are plain application/json, get fully read, and pool normally — which is why mode='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.AsyncClient use 1 connection."
Happy to provide more measurements if useful.
Activity
jagadeepmamidi commentedon Sep 1, 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?
zdurm commentedon Oct 3, 2026
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:streamable_http.pyL377-L388streamable_http.pyL368-L370The 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.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:
1.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
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.