Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport
## Motivation and Context Resolves the `sse-retry` client conformance scenario (SEP-1699, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/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 commit is contained in:
parent
b7bd9bb556
commit
cb0eb3892b
1 changed files with 363 additions and 34 deletions
|
|
@ -35,7 +35,7 @@ module MCP
|
|||
)
|
||||
|
||||
HTTP.any_instance.stubs(:require).with("faraday").returns(true)
|
||||
HTTP.any_instance.stubs(:require).with("event_stream_parser")
|
||||
HTTP.const_get(:SSEStream).any_instance.stubs(:require).with("event_stream_parser")
|
||||
.raises(LoadError, "cannot load such file -- event_stream_parser")
|
||||
|
||||
error = assert_raises(LoadError) do
|
||||
|
|
@ -688,6 +688,169 @@ module MCP
|
|||
|
||||
assert_includes(error.message, "No valid JSON-RPC response found in SSE stream")
|
||||
assert_equal(:parse_error, error.error_type)
|
||||
assert_not_requested(:get, url)
|
||||
end
|
||||
|
||||
def test_send_request_reconnects_with_last_event_id_after_primed_graceful_close
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "test_reconnection", arguments: {} },
|
||||
}
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "id: event-1\nretry: 100\ndata:\n\n",
|
||||
)
|
||||
|
||||
get_body = "id: event-2\nretry: 100\ndata:\n\n" \
|
||||
"event: message\nid: event-3\n" \
|
||||
'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \
|
||||
"\n\n"
|
||||
get_stub = stub_request(:get, url).with(
|
||||
headers: { "Accept" => "text/event-stream", "Last-Event-ID" => "event-1" },
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: get_body,
|
||||
)
|
||||
|
||||
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
response = client.send_request(request: request)
|
||||
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
||||
|
||||
assert_equal({ "content" => [] }, response["result"])
|
||||
assert_requested(get_stub)
|
||||
|
||||
# The server-specified `retry:` interval must elapse before the reconnection GET.
|
||||
assert_operator(elapsed, :>=, 0.1)
|
||||
end
|
||||
|
||||
def test_send_request_uses_default_reconnection_delay_when_retry_field_absent
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "test_reconnection", arguments: {} },
|
||||
}
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "id: event-1\ndata:\n\n",
|
||||
)
|
||||
|
||||
get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \
|
||||
"\n\n"
|
||||
stub_request(:get, url).with(
|
||||
headers: { "Last-Event-ID" => "event-1" },
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: get_body,
|
||||
)
|
||||
|
||||
client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0)
|
||||
|
||||
response = client.send_request(request: request)
|
||||
|
||||
assert_equal({ "content" => [] }, response["result"])
|
||||
end
|
||||
|
||||
def test_send_request_raises_after_reconnection_attempts_are_exhausted
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "test_reconnection", arguments: {} },
|
||||
}
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "id: event-1\nretry: 10\ndata:\n\n",
|
||||
)
|
||||
|
||||
first_get = stub_request(:get, url).with(
|
||||
headers: { "Last-Event-ID" => "event-1" },
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "id: event-2\nretry: 10\ndata:\n\n",
|
||||
)
|
||||
second_get = stub_request(:get, url).with(
|
||||
headers: { "Last-Event-ID" => "event-2" },
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "id: event-3\nretry: 10\ndata:\n\n",
|
||||
)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_includes(error.message, "after 2 reconnection attempts")
|
||||
assert_equal(:internal_error, error.error_type)
|
||||
assert_requested(first_get)
|
||||
assert_requested(second_get)
|
||||
end
|
||||
|
||||
def test_send_request_parses_json_response_when_adapter_does_not_stream
|
||||
# The Faraday test adapter ignores `on_data`, like adapters without
|
||||
# streaming support; the body must be read from `response.body`.
|
||||
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
||||
stub.post("/") do
|
||||
[200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json]
|
||||
end
|
||||
end
|
||||
client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
|
||||
|
||||
response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" })
|
||||
|
||||
assert_equal({ "result" => { "tools" => [] } }, response)
|
||||
end
|
||||
|
||||
def test_send_request_parses_sse_response_when_adapter_does_not_stream
|
||||
sse_body = "event: message\n" \
|
||||
'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \
|
||||
"\n\n"
|
||||
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
||||
stub.post("/") do
|
||||
[200, { "Content-Type" => "text/event-stream" }, sse_body]
|
||||
end
|
||||
end
|
||||
client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
|
||||
|
||||
response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" })
|
||||
|
||||
assert_equal({ "tools" => [] }, response["result"])
|
||||
end
|
||||
|
||||
def test_sse_stream_parses_buffered_chunks_when_env_is_unavailable
|
||||
# Faraday < 2.1 invokes `on_data` without `env`; the content type
|
||||
# cannot be detected, so SSE chunks accumulate in the buffer and are
|
||||
# parsed by the `ingest_pending!` fallback.
|
||||
stream = HTTP.const_get(:SSEStream).new(abortable: true)
|
||||
chunk = "data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{}}\n\n"
|
||||
|
||||
stream.on_data.call(chunk, chunk.bytesize)
|
||||
|
||||
assert_nil(stream.response)
|
||||
assert_equal(chunk, stream.buffer)
|
||||
|
||||
stream.ingest_pending!(nil)
|
||||
|
||||
assert_equal({ "jsonrpc" => "2.0", "id" => "test_id", "result" => {} }, stream.response)
|
||||
assert_empty(stream.buffer)
|
||||
end
|
||||
|
||||
def test_captures_session_id_and_protocol_version_on_initialize
|
||||
|
|
|
|||
Loading…
Reference in a new issue