StreamableHTTP client: _handle_reconnection resets attempt counter to 0, causing infinite retry loop
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 78/100
Línea de trabajo
Comienza en streamable_http.py, en _handle_reconnection, y sigue tanto la ruta de reconexión correcta como las rutas de excepción, centrándote en cómo se pasa attempt a través de la recursión. Ejecuta el reproductor proporcionado server.py y client.py; se considera terminado cuando los streams incompletos repetidos se detienen después de MAX_RECONNECTION_ATTEMPTS y el llamador recibe un error en lugar de quedarse bloqueado indefinidamente.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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
pip install mcp[cli] sse-starlette uvicorn- Run server:
python server.py - Run client:
python client.py - Observe: client logs show repeated
"GET stream disconnected, reconnecting in 1000ms..."messages. Thecall_toolnever returns. After 30s thewait_fortimeout 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.
- Lenguaje dominante
- Python
- Estrellas
- 24.3k
- Forks
- 4k
- Merge medio
- 1 d 19 min
- PR fusionados (30 d)
- 29
Guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de modelcontextprotocol/python-sdk
-
Streamable HTTP client logs a WARNING for valid 202 Accepted on session termination (DELETE) Abiertov1 v2
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
modelcontextprotocol/python-sdk#3546 · 5 comentarios ·
-
v1 v2
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
modelcontextprotocol/python-sdk#3545 · 1 comentario ·
-
v1 v2
Dificultad 1/5 Menos de una hora Aptitud para principiantes 91/100
modelcontextprotocol/python-sdk#3508 · 2 comentarios ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 64/100
modelcontextprotocol/python-sdk#3504 ·
-
v1 v2
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
modelcontextprotocol/python-sdk#3492 · 1 comentario ·
Todos los issues de modelcontextprotocol/python-sdk
Issues similares
-
from:qa priority:P2 reliability tech-debt
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
spec-kitty/spec-kitty#4874 ·
-
fix: inaccuracy ⚠️
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
uabrc/uabrc.github.io#1255 · 1 comentario ·
-
kind:bug needs-triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
-
docs
Dificultad 1/5 Menos de una hora Aptitud para principiantes 85/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
ethereum-optimism/factory#64 ·