Description
The streamable HTTP client can exceed its per-request SSE reconnection budget when each reconnect opens successfully, emits only an id-bearing priming event, and then reaches EOF without a JSON-RPC response.
In that case _handle_reconnection() currently recurses with attempt=0 after the clean EOF path. The exception path increments the counter, but the normal EOF-without-response path resets it, so a no-timeout request such as subscriptions/listen can keep reconnecting instead of resolving the waiter with CONNECTION_CLOSED after MAX_RECONNECTION_ATTEMPTS.
Reproduction
Drive StreamableHTTPTransport._handle_reconnection() with a mock HTTP transport that returns these per-request SSE responses:
- Reconnect 1:
id: evt-1 with empty data, then EOF.
- Reconnect 2:
id: evt-2 with empty data, then EOF.
- Reconnect 3: a JSON-RPC success response.
Starting from Last-Event-ID: evt-0 and retry_interval_ms=0, current main makes the third HTTP request and delivers the success response. I expected the client to stop after the two configured reconnect attempts, emit a JSONRPCError for the original request with CONNECTION_CLOSED, and only send Last-Event-ID: evt-0 and Last-Event-ID: evt-1.
Expected Behavior
Each per-request reconnect that reaches EOF without delivering a JSON-RPC response should consume the reconnect budget. That keeps request-scoped SSE drops consistent whether they end by transport exception or by clean EOF, and prevents no-timeout callers from staying parked forever when a server repeatedly closes resumable streams without producing the response.
Local Verification
I have a local regression test that fails on current main because the third reconnect response is accepted, then passes when the clean EOF path recurses with attempt + 1.
Commands run locally:
uv run --frozen pytest tests/client/test_streamable_http.py::test_empty_resumable_sse_reconnects_count_toward_the_request_budget -q
uv run --frozen pytest tests/client/test_streamable_http.py -q
uv run --frozen ruff check src/mcp/client/streamable_http.py tests/client/test_streamable_http.py
uv run --frozen ruff format --check src/mcp/client/streamable_http.py tests/client/test_streamable_http.py
uv run --frozen pyright src/mcp/client/streamable_http.py tests/client/test_streamable_http.py
UV_FROZEN=1 uv run --frozen strict-no-cover
Disclosure: I used AI assistance to help prepare this report and a local patch; I reviewed the reproduction, root cause, and test results.
Description
The streamable HTTP client can exceed its per-request SSE reconnection budget when each reconnect opens successfully, emits only an id-bearing priming event, and then reaches EOF without a JSON-RPC response.
In that case
_handle_reconnection()currently recurses withattempt=0after the clean EOF path. The exception path increments the counter, but the normal EOF-without-response path resets it, so a no-timeout request such assubscriptions/listencan keep reconnecting instead of resolving the waiter withCONNECTION_CLOSEDafterMAX_RECONNECTION_ATTEMPTS.Reproduction
Drive
StreamableHTTPTransport._handle_reconnection()with a mock HTTP transport that returns these per-request SSE responses:id: evt-1with emptydata, then EOF.id: evt-2with emptydata, then EOF.Starting from
Last-Event-ID: evt-0andretry_interval_ms=0, currentmainmakes the third HTTP request and delivers the success response. I expected the client to stop after the two configured reconnect attempts, emit aJSONRPCErrorfor the original request withCONNECTION_CLOSED, and only sendLast-Event-ID: evt-0andLast-Event-ID: evt-1.Expected Behavior
Each per-request reconnect that reaches EOF without delivering a JSON-RPC response should consume the reconnect budget. That keeps request-scoped SSE drops consistent whether they end by transport exception or by clean EOF, and prevents no-timeout callers from staying parked forever when a server repeatedly closes resumable streams without producing the response.
Local Verification
I have a local regression test that fails on current
mainbecause the third reconnect response is accepted, then passes when the clean EOF path recurses withattempt + 1.Commands run locally:
uv run --frozen pytest tests/client/test_streamable_http.py::test_empty_resumable_sse_reconnects_count_toward_the_request_budget -quv run --frozen pytest tests/client/test_streamable_http.py -quv run --frozen ruff check src/mcp/client/streamable_http.py tests/client/test_streamable_http.pyuv run --frozen ruff format --check src/mcp/client/streamable_http.py tests/client/test_streamable_http.pyuv run --frozen pyright src/mcp/client/streamable_http.py tests/client/test_streamable_http.pyUV_FROZEN=1 uv run --frozen strict-no-coverDisclosure: I used AI assistance to help prepare this report and a local patch; I reviewed the reproduction, root cause, and test results.