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 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

bug P2 v1 v2
主要语言
Python
星标
24.3k
派生
4k
平均合并
1 天 1 小时
30 天内合并 PR
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. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从 src/mcp/client/streamable_http.py 中的 _handle_sse_response 开始,然后针对 SDK 服务器运行提供的连接身份复现。跟踪 legacy SSE 响应的生命周期,并将其与原始 httpx 控制和现代模式进行比较。完成的标准是:串行的 legacy JSON-RPC 交换会复用同一个连接,并为该行为提供稳定的回归检查。

由索引模型根据 Issue 内容生成。

评估

技术栈
python
领域
api, backend
Issue 类型
缺陷
难度
3/5
预计耗时
1-2 天
活跃度
活跃
描述清晰度
描述清楚
新手友好度
76/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。