Skip to content

Commit ab65b91

Browse files
authored
Merge pull request #426 from koic/sse_retry
Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport
2 parents 95feef2 + cb0eb38 commit ab65b91

4 files changed

Lines changed: 363 additions & 34 deletions

File tree

conformance/client.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ def build_provider_for(scenario, context)
135135
add_numbers = tools.find { |t| t.name == "add_numbers" }
136136
abort("Tool add_numbers not found") unless add_numbers
137137
client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 })
138+
when "sse-retry"
139+
# SEP-1699: the server closes the tools/call SSE stream right after a priming event.
140+
# The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`,
141+
# and receives the tool result on the resumed stream; the harness verifies the reconnect,
142+
# its timing, and the header.
143+
tools = client.tools
144+
test_reconnection = tools.find { |t| t.name == "test_reconnection" }
145+
abort("Tool test_reconnection not found") unless test_reconnection
146+
client.call_tool(tool: test_reconnection, arguments: {})
138147
when %r|\Aauth/|
139148
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
140149
# are observed by the conformance server during `connect` and the subsequent request below.

conformance/expected_failures.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
server: []
22
client:
3-
# TODO: SSE reconnection not implemented in Ruby client.
4-
- sse-retry
53
# TODO: Elicitation not implemented in Ruby client.
64
- elicitation-sep1034-client-defaults
75
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.

lib/mcp/client/http.rb

Lines changed: 190 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,24 @@
88

99
module MCP
1010
class Client
11-
# TODO: HTTP GET for SSE streaming is not yet implemented.
11+
# TODO: A standalone HTTP GET listening stream for server-initiated messages is not yet implemented;
12+
# GET is currently used only to resume a request's SSE stream after a disconnect.
1213
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
13-
# TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
14-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
1514
class HTTP
1615
ACCEPT_HEADER = "application/json, text/event-stream"
16+
SSE_ACCEPT_HEADER = "text/event-stream"
1717
SESSION_ID_HEADER = "Mcp-Session-Id"
1818
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
1919
METHOD_HEADER = "Mcp-Method"
2020
NAME_HEADER = "Mcp-Name"
21+
LAST_EVENT_ID_HEADER = "Last-Event-ID"
22+
23+
# SEP-1699 reconnection tuning: the SSE `retry:` field from the server takes precedence;
24+
# this default applies when the server sent none. Both values match the Python SDK
25+
# (`DEFAULT_RECONNECTION_DELAY_MS`, `MAX_RECONNECTION_ATTEMPTS`); the TypeScript SDK uses
26+
# an exponential backoff that the `retry:` field likewise overrides.
27+
DEFAULT_RECONNECTION_DELAY_MS = 1000
28+
MAX_RECONNECTION_ATTEMPTS = 2
2129

2230
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
2331
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
@@ -54,6 +62,101 @@ def call(env)
5462
end
5563
end
5664

65+
# Internal control-flow signal raised inside the streaming callback to stop reading
66+
# an SSE stream once the awaited JSON-RPC response has arrived; servers may hold
67+
# a stream open indefinitely (notably the standalone GET stream), so EOF cannot be relied on.
68+
class StreamAbort < StandardError; end
69+
private_constant :StreamAbort
70+
71+
# Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams:
72+
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
73+
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
74+
class SSEStream
75+
attr_reader :buffer, :response, :last_event_id, :retry_ms
76+
attr_accessor :abortable
77+
78+
def initialize(abortable:)
79+
@abortable = abortable
80+
@buffer = +""
81+
@parser = nil
82+
@response = nil
83+
@last_event_id = nil
84+
@retry_ms = nil
85+
end
86+
87+
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
88+
# which marks the stream as resumable after a graceful close.
89+
def primed?
90+
!@last_event_id.nil?
91+
end
92+
93+
# Faraday `on_data` streaming callback. SSE chunks are parsed incrementally;
94+
# anything else (JSON bodies) accumulates in `buffer`.
95+
def on_data
96+
proc do |chunk, _received_bytes, env|
97+
if event_stream?(env)
98+
feed(chunk)
99+
raise StreamAbort if @abortable && @response
100+
else
101+
@buffer << chunk
102+
end
103+
end
104+
end
105+
106+
# A fresh parser for a new HTTP connection (reconnection GET),
107+
# so a partial line from the previous stream cannot corrupt the next one.
108+
def reset_parser!
109+
@parser = nil
110+
end
111+
112+
# Parses an SSE body that was delivered outside the streaming callback:
113+
# Faraday versions that do not pass `env` to `on_data` leave SSE chunks in `buffer`
114+
# (consumed here so they are not parsed twice), and adapters without `on_data` support
115+
# yield the whole body only via `fallback_body` (the Faraday `response.body`).
116+
def ingest_pending!(fallback_body)
117+
text = @buffer.empty? ? fallback_body : @buffer.slice!(0..-1)
118+
feed(text) if text.is_a?(String) && !text.empty?
119+
end
120+
121+
private
122+
123+
def event_stream?(env)
124+
headers = env&.response_headers
125+
content_type = headers && (headers["content-type"] || headers["Content-Type"])
126+
!!content_type&.include?("text/event-stream")
127+
end
128+
129+
def feed(chunk)
130+
parser.feed(chunk) do |_type, data, id, reconnection_time|
131+
@last_event_id = id if id && !id.empty?
132+
@retry_ms = reconnection_time if reconnection_time
133+
next if data.nil? || data.empty?
134+
135+
begin
136+
parsed = JSON.parse(data)
137+
rescue JSON::ParserError
138+
next
139+
end
140+
141+
if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
142+
@response ||= parsed
143+
end
144+
end
145+
end
146+
147+
def parser
148+
@parser ||= begin
149+
require "event_stream_parser"
150+
EventStreamParser::Parser.new
151+
rescue LoadError
152+
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
153+
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
154+
"See https://rubygems.org/gems/event_stream_parser for more details."
155+
end
156+
end
157+
end
158+
private_constant :SSEStream
159+
57160
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
58161

59162
def initialize(url:, headers: {}, oauth: nil, &block)
@@ -199,11 +302,22 @@ def send_request(request:)
199302
step_up_retried = false
200303

201304
begin
305+
# The response is consumed incrementally so that an SSE stream the server holds open
306+
# (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF
307+
# so the response object (and its `Mcp-Session-Id` header) is always available for capture.
308+
stream = SSEStream.new(abortable: method.to_s != MCP::Methods::INITIALIZE)
202309
yield if block_given?
203-
response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
204-
body = parse_response_body(response, method, params)
310+
response = begin
311+
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
312+
req.options.on_data = stream.on_data
313+
end
314+
rescue StreamAbort
315+
nil
316+
end
317+
318+
body = resolve_response_body(stream, response, method, params)
205319

206-
capture_session_info(method, response, body)
320+
capture_session_info(method, response, body) if response
207321

208322
body
209323
rescue Faraday::BadRequestError => e
@@ -536,25 +650,41 @@ def require_faraday!
536650
"See https://rubygems.org/gems/faraday for more details."
537651
end
538652

539-
def require_event_stream_parser!
540-
require "event_stream_parser"
541-
rescue LoadError
542-
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
543-
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
544-
"See https://rubygems.org/gems/event_stream_parser for more details."
545-
end
653+
# Determines the logical JSON-RPC body of the exchange after the POST stream finished
654+
# (or was aborted because the response already arrived).
655+
def resolve_response_body(stream, response, method, params)
656+
return stream.response if stream.response
546657

547-
def parse_response_body(response, method, params)
548658
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
549659
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
550660
return if response.status == 202
551661

552662
content_type = response.headers["Content-Type"]
553663

554664
if content_type&.include?("text/event-stream")
555-
parse_sse_response(response.body, method, params)
665+
# Nothing was parsed during streaming: either the adapter does not support `on_data`
666+
# (the whole body sits in `response.body`) or Faraday did not pass `env` to `on_data`
667+
# (Faraday < 2.1; the SSE chunks sit in `buffer`). Parse whichever holds the body.
668+
unless stream.primed?
669+
stream.ingest_pending!(response.body)
670+
return stream.response if stream.response
671+
end
672+
673+
# SEP-1699: a graceful close after a priming event (an event with an id) but before
674+
# the response means "reconnect via GET to resume".
675+
return await_response_after_disconnect(stream, method, params) if stream.primed?
676+
677+
raise RequestHandlerError.new(
678+
"No valid JSON-RPC response found in SSE stream",
679+
{ method: method, params: params },
680+
error_type: :parse_error,
681+
)
556682
elsif content_type&.include?("application/json")
557-
response.body
683+
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
684+
685+
# Adapters without `on_data` support deliver the body via `response.body`,
686+
# already parsed by the json response middleware.
687+
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
558688
else
559689
raise RequestHandlerError.new(
560690
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
@@ -564,28 +694,57 @@ def parse_response_body(response, method, params)
564694
end
565695
end
566696

567-
def parse_sse_response(body, method, params)
568-
require_event_stream_parser!
697+
def parse_json_buffer(buffer, method, params)
698+
return if buffer.empty?
569699

570-
json_rpc_response = nil
571-
parser = EventStreamParser::Parser.new
572-
parser.feed(body.to_s) do |_type, data, _id|
573-
next if data.empty?
700+
JSON.parse(buffer)
701+
rescue JSON::ParserError => e
702+
raise RequestHandlerError.new(
703+
"Failed to parse JSON response: #{e.message}",
704+
{ method: method, params: params },
705+
error_type: :parse_error,
706+
)
707+
end
574708

575-
begin
576-
parsed = JSON.parse(data)
577-
json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
578-
rescue JSON::ParserError
579-
next
709+
# SEP-1699 resumability: the server closed the SSE stream after a priming event
710+
# without delivering the response. Treat the graceful close like a network failure:
711+
# wait the server-specified `retry:` interval (default 1000ms), then reconnect with
712+
# a GET carrying `Last-Event-ID` so the server can replay the pending response on
713+
# the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport`
714+
# reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap).
715+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
716+
def await_response_after_disconnect(stream, method, params)
717+
stream.abortable = true
718+
719+
MAX_RECONNECTION_ATTEMPTS.times do
720+
sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
721+
stream.reset_parser!
722+
723+
reconnect_response = begin
724+
client.get("") do |req|
725+
req.headers.update(session_headers)
726+
req.headers["Accept"] = SSE_ACCEPT_HEADER
727+
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
728+
req.options.on_data = stream.on_data
729+
end
730+
rescue StreamAbort
731+
# The awaited response arrived on the reconnected stream.
732+
nil
580733
end
581-
end
582734

583-
return json_rpc_response if json_rpc_response
735+
# Same fallback as `resolve_response_body` for adapters that do not stream via `on_data`.
736+
if reconnect_response && stream.response.nil?
737+
stream.ingest_pending!(reconnect_response.body)
738+
end
739+
740+
return stream.response if stream.response
741+
end
584742

585743
raise RequestHandlerError.new(
586-
"No valid JSON-RPC response found in SSE stream",
744+
"Server closed the SSE stream without a response for #{method} " \
745+
"after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts",
587746
{ method: method, params: params },
588-
error_type: :parse_error,
747+
error_type: :internal_error,
589748
)
590749
end
591750
end

0 commit comments

Comments
 (0)