modelcontextprotocol / modelcontextprotocol/python-sdk

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

Abierto
#3,281 1 comentario 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

bug P2 v1 v2
Lenguaje dominante
Python
Estrellas
24.3k
Forks
4k
Merge medio
1 d 1 h
PR fusionados (30 d)
31

Descripción

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.

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Línea de trabajo

Comienza en src/mcp/client/streamable_http.py, en _handle_sse_response, y luego ejecuta la reproducción proporcionada de la identidad de conexión contra el servidor del SDK. Rastrea el ciclo de vida de la respuesta SSE heredada y compáralo con el control httpx sin procesar y con el modo moderno. Se considera terminado cuando los intercambios JSON-RPC heredados en serie reutilicen una conexión, con una comprobación de regresión estable para ese comportamiento.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
python
Área
api, backend
Tipo de issue
Error
Dificultad
3/5
Tiempo estimado
1-2 días
Estado de actividad
Activo
Claridad
Bien especificado
Aptitud para principiantes
76/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.