Skip to content

fix: return from initialize as soon as its SSE response arrives - #617

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
atesgoral:fix/initialize-sse-abort
Oct 17, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
atesgoral:fix/initialize-sse-abort

Conversation

@atesgoral

@atesgoral atesgoral commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

MCP::Client::HTTP#send_request stops an SSE stream when the JSON-RPC response event arrives. It does not stop the stream for initialize:

stream = SSEStream.new(
  abortable: method.to_s != MCP::Methods::INITIALIZE,
  ...
)

The exception lets capture_session_info read the finished Faraday::Response. The response holds the Mcp-Session-Id header. If the client stops the stream early, post does not return a response object. Session capture then has no response to read.

Some servers send the initialize result over SSE and then keep the stream open. GitMCP is one example. GitMCP is a public stateful server. It sends responses as text/event-stream. It rejects later requests that do not carry the initialize session id. The response arrives in under 0.7 s. The stream stays open for about 10 s (curl: ttfb 0.67 s, total 10.7 s). A client pays about 10 s for each new session. Initialize plus a first call takes about 21 s. The useful work takes under one second. All other methods already return when the response event arrives.

Change

Read the response headers at the point where the code already reads them. Do not wait for the finished response object.

  • SSEStream#on_data saves env.response_headers when it first sees them. The same env gives the content type check for text/event-stream.
  • The request stream uses abortable: true for initialize also.
  • capture_session_info takes headers, not the response object. send_request passes the response headers when a response exists. It passes the headers saved by the stream when the early stop removed the response.
  • The client does not stop the stream on an error status (400 or higher). The finished response still reaches the raise_error middleware in Faraday. The OAuth and step-up retries in send_request then run as before. Error responses are read to the end of the stream. This guard has a regression test below. Without the guard, a 401 with a JSON-RPC error event in the SSE body could return as a normal response. The retry would not run.

Fallbacks

Behavior does not change when stream headers are not available:

  • Faraday before 2.5 calls on_data without env. See test_sse_stream_parses_buffered_chunks_when_env_is_unavailable. The stream saves no headers and does not stop early. SSE chunks collect in buffer. The client reads the stream to the end, as it does today. It still reads the session id from the finished response.
  • Adapters without on_data support, such as the Faraday test adapter, behave the same way.
  • The SEP-1699 reconnection path is not changed. await_response_after_disconnect controls abortable itself. The standalone GET listening stream is also not changed. In a reconnection, headers from the first POST take precedence over headers from the replay GET. The initialize response issues the session.

Reason for the original exception

The exception came with the incremental SSE work in cb0eb38 ("Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport"). The concern was valid. An early stop discards the Faraday::Response. At that time, the response was the only source of Mcp-Session-Id. This change moves header capture to an earlier point. The on_data callback already reads these headers. The success path no longer needs the finished response object. The error path still waits for it. The guard for status 400 or higher keeps that behavior.

No public API changes. capture_session_info is private. send_request is its only caller.

Tests

Both new tests use a real local TCP server. They follow test/mcp/client/oauth/bounded_body_test.rb. WebMock and the Faraday test adapter deliver complete bodies at one time. They cannot show the state "response sent, stream still open."

  • test_initialize_returns_promptly_and_captures_session_id_when_sse_stream_stays_open: the server sends the initialize result event with Mcp-Session-Id, then keeps the stream open for 5 s. The test checks three points. The client returns in under 2.5 s. It saves the session id and protocol version. It sends the session id on the next request. On main, the test fails at 5.01 s. With this change, it passes in well under one second.
  • test_initialize_retries_through_oauth_when_sse_answer_is_401: initialize first receives a 401. The SSE body holds a JSON-RPC error event. After the OAuth retry, the server sends a 200 SSE answer. The test checks that the OAuth flow ran one time. It also checks that the client saved the session from the retried response. The test passes on main and with this change. It fails if the guard for status 400 or higher is removed.

@atesgoral
atesgoral force-pushed the fix/initialize-sse-abort branch from 348e6b3 to 80ecebd Compare October 15, 2026 13:49
@atesgoral
atesgoral marked this pull request as draft October 15, 2026 13:55
@atesgoral
atesgoral force-pushed the fix/initialize-sse-abort branch from 80ecebd to 43dc0f8 Compare October 15, 2026 14:05
@atesgoral
atesgoral marked this pull request as ready for review October 15, 2026 15:42
@koic
koic merged commit 0d4b3f3 into modelcontextprotocol:main Oct 17, 2026
11 checks passed
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.

2 participants