88
99module 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