Skip to content

Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport#426

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:sse_retry
Open

Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport#426
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:sse_retry

Conversation

@koic

@koic koic commented Jun 24, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## 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.
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.

1 participant