Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport#426
Open
koic wants to merge 1 commit into
Open
Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport#426koic wants to merge 1 commit into
koic wants to merge 1 commit into
Conversation
## Motivation and Context Resolves the `sse-retry` client conformance scenario (SEP-1699, modelcontextprotocol/modelcontextprotocol#1699). Per SEP-1699, a server may close a request's SSE stream right after a priming event (an event carrying an `id:`) without delivering the response, expecting the client to treat the graceful close like a network failure: wait the server-specified `retry:` interval, then reconnect with an HTTP GET carrying `Last-Event-ID` so the server can replay the pending response on the resumed stream. The Ruby client previously read the entire SSE body to EOF and parsed it afterward, so it had no notion of per-event state (`id:`, `retry:`) or reconnection, and the scenario failed all three checks (graceful reconnect, retry timing, `Last-Event-ID`). `MCP::Client::HTTP#send_request` now consumes responses incrementally via Faraday's `on_data` streaming callback, feeding SSE chunks to `event_stream_parser` as they arrive while buffering plain JSON bodies as before. A new internal `SSEStream` tracks the last received event id, the `retry:` reconnection delay, and the awaited JSON-RPC response; once the response arrives, a held-open stream is abandoned via an internal control-flow exception since servers may never close it. When a stream closes gracefully after a priming event but before the response, the client sleeps for the `retry:` interval (default 1000ms when the server sent none), then issues a GET with `Accept: text/event-stream`, the session headers, and `Last-Event-ID`, for up to 2 attempts. The defaults match the Python SDK's `DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` (`streamable_http.py`); the TypeScript SDK's `StreamableHTTPClientTransport` (`streamableHttp.ts`) likewise lets the `retry:` field override its backoff and reconnects only when a priming event was received and no response has arrived. The public behavior of `send_request` is unchanged: same return values, same error mapping, and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error without reconnecting. Because the previous implementation read `response.body` and therefore worked with any Faraday adapter, a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read from `response.body` (adapters without `on_data` support, e.g. the Faraday test adapter) or from the buffered chunks (Faraday < 2.1, which invokes `on_data` without `env`). The conformance client gains an `sse-retry` branch that calls the harness's `test_reconnection` tool, and the scenario is removed from the expected failures baseline. ## How Has This Been Tested? New unit tests covering: reconnection with `Last-Event-ID` after a primed graceful close including the `retry:` wait, the default 1000ms delay when `retry:` is absent, raising after the reconnection attempt cap with `Last-Event-ID` advancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks (JSON and SSE responses through the Faraday test adapter, which ignores `on_data`, plus buffered SSE chunks when `on_data` receives no `env`) ## Breaking Changes None. The `send_request` contract is unchanged; SSE bodies are now parsed incrementally instead of after EOF, and reconnection only activates when the server opts in by sending a priming event.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
Resolves the
sse-retryclient conformance scenario (SEP-1699, modelcontextprotocol/modelcontextprotocol#1699).Per SEP-1699, a server may close a request's SSE stream right after a priming event (an event carrying an
id:) without delivering the response, expecting the client to treat the graceful close like a network failure: wait the server-specifiedretry:interval, then reconnect with an HTTP GET carryingLast-Event-IDso the server can replay the pending response on the resumed stream.The Ruby client previously read the entire SSE body to EOF and parsed it afterward, so it had no notion of per-event state (
id:,retry:) or reconnection, and the scenario failed all three checks (graceful reconnect, retry timing,Last-Event-ID).MCP::Client::HTTP#send_requestnow consumes responses incrementally via Faraday'son_datastreaming callback, feeding SSE chunks toevent_stream_parseras they arrive while buffering plain JSON bodies as before. A new internalSSEStreamtracks the last received event id, theretry:reconnection delay, and the awaited JSON-RPC response; once the response arrives, a held-open stream is abandoned via an internal control-flow exception since servers may never close it. When a stream closes gracefully after a priming event but before the response, the client sleeps for theretry:interval (default 1000ms when the server sent none), then issues a GET withAccept: text/event-stream, the session headers, andLast-Event-ID, for up to 2 attempts. The defaults match the Python SDK'sDEFAULT_RECONNECTION_DELAY_MSandMAX_RECONNECTION_ATTEMPTS(streamable_http.py); the TypeScript SDK'sStreamableHTTPClientTransport(streamableHttp.ts) likewise lets theretry:field override its backoff and reconnects only when a priming event was received and no response has arrived.The public behavior of
send_requestis unchanged: same return values, same error mapping, and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error without reconnecting. Because the previous implementation readresponse.bodyand therefore worked with any Faraday adapter, a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read fromresponse.body(adapters withouton_datasupport, e.g. the Faraday test adapter) or from the buffered chunks (Faraday < 2.1, which invokeson_datawithoutenv).The conformance client gains an
sse-retrybranch that calls the harness'stest_reconnectiontool, and the scenario is removed from the expected failures baseline.How Has This Been Tested?
New unit tests covering: reconnection with
Last-Event-IDafter a primed graceful close including theretry:wait, the default 1000ms delay whenretry:is absent, raising after the reconnection attempt cap withLast-Event-IDadvancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks (JSON and SSE responses through the Faraday test adapter, which ignoreson_data, plus buffered SSE chunks whenon_datareceives noenv)Breaking Changes
None. The
send_requestcontract is unchanged; SSE bodies are now parsed incrementally instead of after EOF, and reconnection only activates when the server opts in by sending a priming event.Types of changes
Checklist