Skip to content

streamable_http (legacy mode): early response.aclose() discards a TCP connection per JSON-RPC exchange — deterministic reproduction for #2707 #3281

Description

@pvarshh

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

  1. added
    v2Affects the v2 line (2.x on main)
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Aug 11, 2026
  2. added
    v1Affects the v1.x maintenance line
    on Aug 18, 2026
  3. jagadeepmamidi commented on Sep 1, 2026

    @jagadeepmamidi

    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?

  4. zdurm commented on Oct 3, 2026

    @zdurm

    I can confirm this on the latest 2.x (2.3.0, which is also current main at 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: the 202 Accepted branch of _handle_post_request.

    The 202 path. For every notification POST (notifications/initialized, notifications/cancelled, ...) the server answers 202 Accepted with an empty body. The client returns from inside the async with stream_within_origin(...) block without reading that body:

    The body is empty, but h11 only reaches DONE for the response after the client reads the end of the message. httpcore's _response_closed returns the connection to the pool only when both sides are DONE, otherwise it closes it. So every notification costs one TCP (plus TLS) connection, the same way the SSE response.aclose() (2.3.0 L500-L502, 1.30.0 L449-L451) does for each request. The httpx2 switch on main does not change this.

    Measurement. The script below runs initialize, notifications/initialized, tools/list and one tools/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 the network_stream object in response.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-202 reads the 202 body, drain-sse reads other bodies to EOF before close (what #2712 does), drain-all does 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, main and 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: 0
    

    1.30.0 (uv run --no-project --python 3.12 --with 'mcp==1.30.0' python repro.py) and main at 2118f14 print the same counts in every run (only the position of the GET line in drain-all differs 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.

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

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething 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