modelcontextprotocol / modelcontextprotocol/python-sdk

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

オープン
#3,281 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

bug P2 v1 v2
主要言語
Python
スター
24.3k
フォーク
4k
平均マージ
1日 1時間
マージ済み PR(30日)
31

説明

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.

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

src/mcp/client/streamable_http.py の _handle_sse_response から始め、続いて SDK サーバーに対して、提供された接続 ID の再現手順を実行します。レガシー SSE レスポンスのライフサイクルを追跡し、raw httpx の制御およびモダンモードと比較します。シリアルなレガシー JSON-RPC 交換で 1 つの接続が再利用され、その動作に対する安定したリグレッションチェックがあることを完了条件とします。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
api, backend
issue の種類
バグ
難易度
3/5
見積もり時間
1〜2日
活発さ
活発
明瞭さ
明確に書かれている
初心者へのやさしさ
76/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。