modelcontextprotocol / modelcontextprotocol/python-sdk

StreamableHTTP client: _handle_reconnection resets attempt counter to 0, causing infinite retry loop

オープン 初心者向け
#2,393 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る

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

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

説明

Bug Description

_handle_reconnection() in streamable_http.py resets the attempt counter to 0 on line 494 when a reconnection succeeds but the stream ends without delivering a complete response. This makes MAX_RECONNECTION_ATTEMPTS ineffective — the counter only applies to consecutive exceptions, not total reconnection attempts. If the server accepts the connection but the stream drops repeatedly, the client retries forever.

Reproduction

MCP version: 1.26.0 (also confirmed unpatched in 1.27.0 main branch)

SSCE — Minimal reproducer

Server (server.py): A server that accepts SSE connections but closes them before sending a complete response, with a last-event-id header to trigger the reconnection path.

"""Minimal MCP server that drops SSE streams to trigger infinite reconnect."""
import asyncio
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import Response
from sse_starlette.sse import EventSourceResponse
import uvicorn, json, uuid

session_id = str(uuid.uuid4())

async def handle_mcp(request):
    if request.method == "POST":
        body = await request.json()
        method = body.get("method")

        if method == "initialize":
            resp = {
                "jsonrpc": "2.0",
                "id": body["id"],
                "result": {
                    "protocolVersion": "2025-06-18",
                    "capabilities": {"tools": {}},
                    "serverInfo": {"name": "drop-server", "version": "0.1.0"},
                },
            }
            return Response(
                json.dumps(resp),
                media_type="application/json",
                headers={"mcp-session-id": session_id},
            )

        if method == "notifications/initialized":
            return Response(status_code=202, headers={"mcp-session-id": session_id})

        if method == "tools/call":
            # Return SSE that sends a priming event with an ID, then drops
            async def event_generator():
                yield {"event": "message", "id": "evt-1", "data": ""}
                # Close without sending the actual response
                return

            return EventSourceResponse(
                event_generator(),
                headers={"mcp-session-id": session_id},
            )

        return Response(status_code=404)

    if request.method == "GET":
        # GET stream for server-initiated messages — also drop immediately
        async def get_stream():
            yield {"event": "message", "id": "evt-get-1", "data": ""}
            return

        return EventSourceResponse(
            get_stream(),
            headers={"mcp-session-id": session_id},
        )

    if request.method == "DELETE":
        return Response(status_code=200)

app = Starlette(routes=[Route("/mcp", handle_mcp, methods=["POST", "GET", "DELETE"])])

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=9999)

Client (client.py): Connects and calls a tool, demonstrating the infinite loop.

"""Client that demonstrates infinite reconnection loop."""
import asyncio, logging
logging.basicConfig(level=logging.INFO)

from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

async def main():
    async with streamable_http_client("http://localhost:9999/mcp") as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            print("Initialized. Calling tool (will hang forever)...")
            # This call will trigger _handle_reconnection with attempt=0 reset
            try:
                result = await asyncio.wait_for(
                    session.call_tool("any_tool", {}),
                    timeout=30,
                )
            except asyncio.TimeoutError:
                print("CONFIRMED: call_tool hung for 30s (infinite reconnect loop)")

asyncio.run(main())
Steps
  1. pip install mcp[cli] sse-starlette uvicorn
  2. Run server: python server.py
  3. Run client: python client.py
  4. Observe: client logs show repeated "GET stream disconnected, reconnecting in 1000ms..." messages. The call_tool never returns. After 30s the wait_for timeout fires, confirming the hang.

Root Cause

In streamable_http.py, _handle_reconnection (line 437):

async def _handle_reconnection(self, ctx, last_event_id, retry_interval_ms, attempt=0):
    if attempt >= MAX_RECONNECTION_ATTEMPTS:  # Only 2
        return

    # ... reconnects, iterates SSE ...

    # Line 494: Stream ended without response — resets attempt to 0!
    await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)

When the reconnection succeeds (HTTP 200) but the stream ends without a complete JSONRPCResponse, line 494 recurses with attempt=0, restarting the counter. Only the exception path (line 498) increments the counter. A server that accepts connections but drops streams causes infinite recursion at 1-second intervals.

Expected Behavior

After MAX_RECONNECTION_ATTEMPTS total reconnection attempts (regardless of whether they succeeded at the HTTP level), the client should give up and propagate an error to the caller.

Suggested Fix

Track total attempts across the recursion rather than resetting on successful connect:

# Line 494: increment instead of reset
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, attempt + 1)

Or add a separate max_total_reconnection_attempts counter that is never reset.

Impact

In production, this causes MCP client coroutines to hang forever when a server experiences transient stream drops. The calling application has no way to recover without wrapping every MCP call in asyncio.wait_for(). We discovered this when an agentquant research job hung for 5+ hours in a reconnection loop after an MCP server's SSE stream dropped.

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

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

はじめの一歩

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

調査の方向性

streamable_http.py の _handle_reconnection から始め、再接続に成功する経路と例外経路の両方を追跡し、attempt が再帰を通じてどのように渡されるかに注目してください。提供されている server.py と client.py の再現コードを実行してください。繰り返し発生する不完全なストリームが MAX_RECONNECTION_ATTEMPTS の後に停止し、呼び出し元が無期限にハングするのではなくエラーを受け取れば完了です。

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

評価

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

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

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