modelcontextprotocol / modelcontextprotocol/python-sdk

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

Ouverte
#3,281 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

bug P2 v1 v2
Langage dominant
Python
Étoiles
24.3k
Forks
4k
Merge moyen
1 j 1 h
PR mergées (30 j)
31

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.

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Commencez dans src/mcp/client/streamable_http.py, au niveau de _handle_sse_response, puis exécutez la reproduction fournie de l’identité de connexion contre le serveur SDK. Suivez le cycle de vie de la réponse SSE legacy et comparez-le au contrôle httpx brut ainsi qu’au mode moderne. Le travail est terminé lorsque les échanges JSON-RPC legacy en série réutilisent une seule connexion, avec une vérification de régression stable pour ce comportement.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
python
Domaine
api, backend
Type d'issue
Bug
Difficulté
3/5
Temps estimé
1-2 jours
Activité
Active
Clarté
Clairement spécifiée
Accessibilité débutants
76/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.