Skip to content

Fix infinite reconnection loop in StreamableHTTP client#2395

Open
Dharit13 wants to merge 1 commit intomodelcontextprotocol:mainfrom
Dharit13:fix/reconnection-attempt-counter-reset
Open

Fix infinite reconnection loop in StreamableHTTP client#2395
Dharit13 wants to merge 1 commit intomodelcontextprotocol:mainfrom
Dharit13:fix/reconnection-attempt-counter-reset

Conversation

@Dharit13
Copy link
Copy Markdown

@Dharit13 Dharit13 commented Apr 5, 2026

Summary

Fixes #2393.

_handle_reconnection() reset the attempt counter to 0 when the SSE stream ended without delivering a complete response (only priming events). This made MAX_RECONNECTION_ATTEMPTS ineffective — a server that accepts connections but always drops streams caused the client to retry forever, hanging the caller indefinitely.

Root cause: Line 426 in streamable_http.py recursed with attempt=0 whenever the stream ended without a JSONRPCResponse/JSONRPCError, regardless of whether any useful data was received.

Fix: Distinguish productive reconnections (ones that delivered actual message data like notifications) from unproductive ones (only priming events). Productive reconnections reset the counter so legitimate multi-close patterns (e.g., SSE polling with server-side disconnect) continue working. Unproductive reconnections increment the counter. When MAX_RECONNECTION_ATTEMPTS is reached, the client now sends a JSONRPCError back through the read stream so the caller gets unblocked instead of hanging forever.

Changes

  • src/mcp/client/streamable_http.py: Track whether actual message data (not just priming events) was received during a reconnection. Only reset the attempt counter when data was delivered. Send an error response to the caller when max attempts are exceeded.
  • tests/shared/test_streamable_http.py: Add tool_with_perpetual_stream_close (server tool that always closes the stream without responding) and test_reconnection_gives_up_after_max_attempts (regression test proving the client gives up and raises MCPError instead of looping forever).

How Has This Been Tested

  • New regression test test_reconnection_gives_up_after_max_attempts — verifies the client raises MCPError after max attempts with a fail-after timeout of 30s (completes in ~2s)
  • All 62 existing test_streamable_http.py tests pass, including the multi-reconnection tests (test_streamable_http_multiple_reconnections, test_streamable_http_client_auto_reconnects, test_standalone_get_stream_reconnection)
  • pyright — 0 errors
  • ruff check + ruff format — clean
  • strict-no-cover — clean

Breaking Changes

None. Productive reconnections (where actual data is delivered before the stream drops) continue to reset the counter, preserving existing behavior. Only unproductive reconnections (no message data, just priming events) now correctly increment toward the limit.

Made with Cursor

@Dharit13 Dharit13 force-pushed the fix/reconnection-attempt-counter-reset branch from d71da1d to 98c9b5f Compare April 5, 2026 18:53
_handle_reconnection() reset the attempt counter to 0 when the SSE
stream ended without delivering a complete response (only priming
events). This made MAX_RECONNECTION_ATTEMPTS ineffective—a server
that accepts connections but drops streams caused the client to retry
forever.

The fix distinguishes productive reconnections (ones that delivered
actual message data like notifications) from unproductive ones (only
priming events or nothing). Productive reconnections reset the counter
so legitimate multi-close patterns continue working. Unproductive
reconnections increment the counter, and once MAX_RECONNECTION_ATTEMPTS
is reached the client sends an error back to the caller instead of
silently returning (which caused the caller to hang).

Made-with: Cursor
Github-Issue: modelcontextprotocol#2393
Made-with: Cursor
@Dharit13 Dharit13 force-pushed the fix/reconnection-attempt-counter-reset branch from 98c9b5f to 35eff18 Compare April 5, 2026 18:57
@Dharit13
Copy link
Copy Markdown
Author

Dharit13 commented Apr 5, 2026

CI Green — Summary

All 25 checks pass (Python 3.10–3.14 × Ubuntu/Windows, conformance, pre-commit, readme-snippets).

The bug

_handle_reconnection() recursed with attempt=0 on line 426 when the SSE stream ended without a complete JSONRPCResponse/JSONRPCError. This reset the retry counter on every reconnection that succeeded at the HTTP level but dropped before delivering a response. A server that accepts connections and sends priming events but never completes caused the client to retry forever, hanging the caller indefinitely.

Discovered in production when an MCP client coroutine hung for 5+ hours in a reconnection loop after a server's SSE stream kept dropping.

The fix (2 files, +62/−9 lines)

src/mcp/client/streamable_http.py

  • Track whether actual message data (not just empty priming events) was received during each reconnection attempt.
  • Productive reconnections (data delivered) → reset counter to 0. This preserves the existing SSE polling pattern where the server intentionally closes streams after delivering notifications.
  • Unproductive reconnections (only priming events / no data) → increment counter toward MAX_RECONNECTION_ATTEMPTS.
  • When max attempts is reached, send a JSONRPCError back through the read stream so the caller gets unblocked with MCPError instead of hanging forever.

tests/shared/test_streamable_http.py

  • Added tool_with_perpetual_stream_close — a test tool that always closes the stream without ever sending a response.
  • Added test_reconnection_gives_up_after_max_attempts — regression test that verifies the client raises MCPError after max attempts (completes in ~2s with a 30s safety timeout).

What's NOT changed

Productive reconnections (where the server delivers notifications before closing the stream) still reset the counter. All existing reconnection tests pass unchanged: test_streamable_http_multiple_reconnections (3 consecutive close/reconnect cycles), test_streamable_http_client_auto_reconnects, test_standalone_get_stream_reconnection, test_streamable_http_sse_polling_full_cycle, and test_streamable_http_events_replayed_after_disconnect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants