modelcontextprotocol / modelcontextprotocol/python-sdk
streamable_http (legacy mode): early response.aclose() discards a TCP connection per JSON-RPC exchange — deterministic reproduction for #2707
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- Python
- Star
- 24.3k
- Fork
- 4k
- Merge trung bình
- 1 ngày 1 giờ
- Pull request đã merge (30 ngày)
- 31
Mô tả
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.
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu trong src/mcp/client/streamable_http.py tại _handle_sse_response, sau đó chạy bản tái hiện identity của connection được cung cấp đối với máy chủ SDK. Theo dõi vòng đời của phản hồi SSE legacy và so sánh với cơ chế kiểm soát httpx thô cũng như chế độ hiện đại. Được xem là hoàn tất khi các trao đổi JSON-RPC legacy tuần tự sử dụng lại một connection, kèm theo một kiểm tra hồi quy ổn định cho hành vi đó.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- python
- Lĩnh vực
- api, backend
- Loại issue
- Lỗi
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức độ hoạt động
- Sôi nổi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức phù hợp với người mới
- 76/100