Skip to content

Commit 348e6b3

Browse files
committed
fix: return from initialize as soon as its SSE response arrives
1 parent 0019b4d commit 348e6b3

2 files changed

Lines changed: 151 additions & 8 deletions

File tree

‎lib/mcp/client/http.rb‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ class MessageTooLargeError < StandardError; end
120120
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
121121
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
122122
class SSEStream
123-
attr_reader :buffer, :response, :last_event_id, :retry_ms
123+
attr_reader :buffer, :response, :response_headers, :last_event_id, :retry_ms
124124
attr_accessor :abortable
125125

126126
def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
@@ -132,6 +132,7 @@ def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil
132132
@response = nil
133133
@last_event_id = nil
134134
@retry_ms = nil
135+
@response_headers = nil
135136
end
136137

137138
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
@@ -146,9 +147,10 @@ def primed?
146147
# abort the stream so an over-limit tail cannot mask a response that already arrived.
147148
def on_data
148149
proc do |chunk, _received_bytes, env|
150+
@response_headers ||= env.response_headers if env
149151
if event_stream?(env)
150152
feed(chunk)
151-
raise StreamAbort if @abortable && @response
153+
raise StreamAbort if @abortable && @response && !error_status?(env)
152154

153155
if parser_buffered_bytes > @max_message_bytes
154156
raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
@@ -163,6 +165,15 @@ def on_data
163165
end
164166
end
165167

168+
# An error status must reach Faraday's `raise_error` middleware (which drives the
169+
# OAuth and step-up retries in `send_request`), so the stream is read to EOF instead
170+
# of aborting when the server answered with one — the pre-abort behavior for every
171+
# method, and the only case where the finished response object is still load-bearing.
172+
def error_status?(env)
173+
status = env.status if env.respond_to?(:status)
174+
status.is_a?(Integer) && status >= 400
175+
end
176+
166177
# A fresh parser for a new HTTP connection (reconnection GET),
167178
# so a partial line from the previous stream cannot corrupt the next one.
168179
def reset_parser!
@@ -408,10 +419,13 @@ def send_request(request:)
408419

409420
begin
410421
# The response is consumed incrementally so that an SSE stream the server holds open
411-
# (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF
412-
# so the response object (and its `Mcp-Session-Id` header) is always available for capture.
422+
# (or closes early per SEP-1699) can be handled. Every request, `initialize` included,
423+
# aborts once its response event arrives: `SSEStream` has already captured the response
424+
# headers from the streaming `env` by then, so `Mcp-Session-Id` survives the abort.
425+
# When `env` is unavailable (Faraday < 2.1 or an adapter without `on_data` support)
426+
# nothing aborts and the stream is still read to EOF, as before.
413427
stream = SSEStream.new(
414-
abortable: method.to_s != MCP::Methods::INITIALIZE,
428+
abortable: true,
415429
max_message_bytes: @max_message_bytes,
416430
on_request: ->(message) { dispatch_server_request(message) },
417431
)
@@ -434,7 +448,8 @@ def send_request(request:)
434448
# anything is learned from the body, and only for a request: a notification awaits no response.
435449
reject_invalid_response!(body, method, params) if request_id
436450

437-
capture_session_info(method, response, body) if response
451+
response_headers = response ? response.headers : stream.response_headers
452+
capture_session_info(method, response_headers, body) if response_headers
438453
capture_mcp_param_declarations(method, params, body)
439454

440455
body
@@ -963,11 +978,12 @@ def refresh_token_available?
963978
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
964979
end
965980

966-
def capture_session_info(method, response, body)
981+
def capture_session_info(method, headers, body)
967982
return unless method.to_s == Methods::INITIALIZE
983+
return unless headers
968984

969985
# Faraday normalizes header names to lowercase.
970-
session_id = response.headers[SESSION_ID_HEADER.downcase]
986+
session_id = headers[SESSION_ID_HEADER.downcase]
971987
@session_id ||= session_id unless session_id.to_s.empty?
972988
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
973989
end

‎test/mcp/client/http_test.rb‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1914,6 +1914,133 @@ def test_captures_session_id_and_protocol_version_on_initialize
19141914
assert_equal("2025-11-25", client.protocol_version)
19151915
end
19161916

1917+
def test_initialize_returns_promptly_and_captures_session_id_when_sse_stream_stays_open
1918+
require "socket"
1919+
1920+
hold_seconds = 5
1921+
seen_session_ids = []
1922+
WebMock.disable!
1923+
server = TCPServer.new("127.0.0.1", 0)
1924+
connection_threads = []
1925+
server_thread = Thread.new do
1926+
2.times do |index|
1927+
socket = server.accept
1928+
connection_threads << Thread.new(socket, index) do |conn, conn_index|
1929+
request_headers = {}
1930+
conn.gets
1931+
while (line = conn.gets) && line != "\r\n"
1932+
name, value = line.split(": ", 2)
1933+
request_headers[name.downcase] = value&.strip
1934+
end
1935+
content_length = request_headers["content-length"].to_i
1936+
conn.read(content_length) if content_length.positive?
1937+
seen_session_ids << request_headers["mcp-session-id"]
1938+
if conn_index.zero?
1939+
event = 'data: {"jsonrpc":"2.0","id":"1","result":{"protocolVersion":"2025-11-25"}}' + "\n\n"
1940+
conn.write(
1941+
"HTTP/1.1 200 OK\r\n" \
1942+
"Content-Type: text/event-stream\r\n" \
1943+
"Mcp-Session-Id: session-held-open\r\n" \
1944+
"Connection: close\r\n\r\n",
1945+
)
1946+
conn.write(event)
1947+
conn.flush
1948+
sleep(hold_seconds)
1949+
else
1950+
body = { jsonrpc: "2.0", result: { tools: [] } }.to_json
1951+
conn.write(
1952+
"HTTP/1.1 200 OK\r\n" \
1953+
"Content-Type: application/json\r\n" \
1954+
"Content-Length: #{body.bytesize}\r\n" \
1955+
"Connection: close\r\n\r\n#{body}",
1956+
)
1957+
end
1958+
conn.close
1959+
end
1960+
end
1961+
rescue IOError, SystemCallError
1962+
nil
1963+
end
1964+
begin
1965+
held_client = HTTP.new(url: "http://127.0.0.1:#{server.addr[1]}/")
1966+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1967+
held_client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
1968+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
1969+
assert_operator(elapsed, :<, hold_seconds / 2.0, "initialize waited for the held-open SSE stream to close")
1970+
assert_equal("session-held-open", held_client.session_id)
1971+
assert_equal("2025-11-25", held_client.protocol_version)
1972+
held_client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
1973+
server_thread.join(5)
1974+
assert_equal("session-held-open", seen_session_ids[1])
1975+
ensure
1976+
connection_threads.each(&:kill)
1977+
connection_threads.each { |thread| thread.join(1) }
1978+
server_thread.kill
1979+
server.close unless server.closed?
1980+
end
1981+
ensure
1982+
WebMock.enable!
1983+
end
1984+
1985+
def test_initialize_retries_through_oauth_when_sse_answer_is_401
1986+
require "socket"
1987+
1988+
WebMock.disable!
1989+
server = TCPServer.new("127.0.0.1", 0)
1990+
connection_threads = []
1991+
server_thread = Thread.new do
1992+
2.times do |index|
1993+
socket = server.accept
1994+
connection_threads << Thread.new(socket, index) do |conn, conn_index|
1995+
conn.gets
1996+
headers = {}
1997+
while (line = conn.gets) && line != "\r\n"
1998+
name, value = line.split(": ", 2)
1999+
headers[name.downcase] = value&.strip
2000+
end
2001+
length = headers["content-length"].to_i
2002+
conn.read(length) if length.positive?
2003+
if conn_index.zero?
2004+
event = 'data: {"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"unauthorized"}}' + "\n\n"
2005+
conn.write(
2006+
"HTTP/1.1 401 Unauthorized\r\n" \
2007+
"Content-Type: text/event-stream\r\n" \
2008+
"Connection: close\r\n\r\n#{event}",
2009+
)
2010+
else
2011+
event = 'data: {"jsonrpc":"2.0","id":"1","result":{"protocolVersion":"2025-11-25"}}' + "\n\n"
2012+
conn.write(
2013+
"HTTP/1.1 200 OK\r\n" \
2014+
"Content-Type: text/event-stream\r\n" \
2015+
"Mcp-Session-Id: session-after-retry\r\n" \
2016+
"Connection: close\r\n\r\n#{event}",
2017+
)
2018+
end
2019+
conn.close
2020+
end
2021+
end
2022+
rescue IOError, SystemCallError
2023+
nil
2024+
end
2025+
begin
2026+
oauth_client = HTTP.new(url: "http://127.0.0.1:#{server.addr[1]}/", oauth: Struct.new(:access_token).new)
2027+
oauth_calls = 0
2028+
oauth_client.define_singleton_method(:run_oauth_flow!) { |unauthorized_error:| oauth_calls += 1 }
2029+
oauth_client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
2030+
server_thread.join(5)
2031+
assert_equal(1, oauth_calls, "the 401 must reach the OAuth retry instead of being returned as a response")
2032+
assert_equal("session-after-retry", oauth_client.session_id)
2033+
assert_equal("2025-11-25", oauth_client.protocol_version)
2034+
ensure
2035+
connection_threads.each(&:kill)
2036+
connection_threads.each { |thread| thread.join(1) }
2037+
server_thread.kill
2038+
server.close unless server.closed?
2039+
end
2040+
ensure
2041+
WebMock.enable!
2042+
end
2043+
19172044
def test_includes_session_and_protocol_version_headers_after_initialize
19182045
stub_request(:post, url)
19192046
.to_return(

0 commit comments

Comments
 (0)