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.
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.