MCP 2026-07-28 (3/11): Streamable HTTP modern mode — no sessions, request metadata headers, era detection - #226
Open
simonx1 wants to merge 9 commits into
Open
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
simonx1
force-pushed
the
mcp-2026/streamable-http-modern
branch
from
September 1, 2026 19:07
7aac44b to
34e044a
Compare
2 tasks
simonx1
force-pushed
the
mcp-2026/streamable-http-modern
branch
from
September 1, 2026 19:55
f5bc6d6 to
39ae5ba
Compare
simonx1
force-pushed
the
mcp-2026/stateless-stdio
branch
from
September 5, 2026 00:17
7728efb to
9252e81
Compare
simonx1
force-pushed
the
mcp-2026/streamable-http-modern
branch
from
September 5, 2026 00:19
0e00bfa to
725e88f
Compare
simonx1
force-pushed
the
mcp-2026/stateless-stdio
branch
from
September 5, 2026 12:31
255bda5 to
7e7b46a
Compare
simonx1
force-pushed
the
mcp-2026/streamable-http-modern
branch
2 times, most recently
from
September 5, 2026 18:46
2a640ac to
193e171
Compare
simonx1
force-pushed
the
mcp-2026/stateless-stdio
branch
from
September 5, 2026 18:56
f851b0f to
a4cfc1a
Compare
Era detection: a server/discover POST goes first. A DiscoverResult, or a recognized modern JSON-RPC error in a 400 body (UnsupportedProtocolVersion retried with an advertised version; HeaderMismatch and MissingRequiredClientCapability surfaced), marks the server modern; a 404 carrying -32601 is a modern server without discovery; any other 4xx, or a 2xx that is not a DiscoverResult, is a legacy server and the initialize handshake runs as before (cached for the transport's life). 401/403, 5xx and timeouts propagate. protocol: :auto/:modern/:legacy and discover_timeout: on both HTTP transports, the config builders, the factory and MCPClient.connect. Modern requests: MCP-Protocol-Version (matching the body _meta), Mcp-Method and Mcp-Name (Base64 sentinel encoding when not header-safe) on every POST; no Mcp-Session-Id, GET stream, DELETE or Last-Event-ID. Closing the stream is the cancellation (no notifications/cancelled). A response stream that ends without the response re-issues idempotent requests with a new id and surfaces tools/call. Server-initiated requests on response streams are dropped. ping -> server/discover, log_level= -> _meta logLevel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…connect Codex + grok findings on the Streamable HTTP modern mode: - A bare -32022 without data.supported is a legacy answer (fall back); a well-formed rejection settles the era so a failing retried probe never falls back; a 2xx scalar/array/null probe answer falls back too. - Every server/discover answer (including the ping heartbeat) is validated and applied; removed notifications are suppressed inside rpc_notify once the era is negotiated; inline version renegotiation compares against the version the rejected request was sent with; tasks/result routes on taskId. - ServerHTTP now advertises Accept: application/json, text/event-stream and parses SSE-framed responses (forwarding request-scoped notifications, dropping server-initiated requests), as the transport spec requires of every client. - connect is serialized on the transport monitor so concurrent first requests run the probe once. - Legacy integration fixtures pin protocol: :legacy, and spec_helper answers any unstubbed server/discover POST as a legacy endpoint would (404, no body) so no fixture reaches the network. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… tentative version Codex second-round findings on the Streamable HTTP modern mode: - Modern notifications carry the per-request _meta like requests, so the MCP-Protocol-Version header always matches the body. - An SSE response stream that ends empty (or, on the plain HTTP transport, without the response) is classified as a closed stream on a modern session and re-issued with a new id like any other broken stream. - A DiscoverResult with no mutual version (or an authorization failure on the probe) clears the tentative protocol version before propagating. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Codex verification of PR 3 found five ways a transport failure could be mistaken for a protocol verdict, and one place the published re-issue rule was not applied. - The server/discover probe now goes through the modern re-issue path: a probe whose response stream dies is re-sent once with a new request id. Previously it raised ResponseStreamClosedError into the generic TransportError rescue, which cached a permanent legacy verdict for a modern server. - Only a genuine rejection settles the era. Errors that leave the exchange incomplete answer era_inconclusive? and now propagate instead of recording a verdict: broken streams, timeouts, oversized bodies and 5xx. A 5xx surfaced as an exception by user-configured raise_error middleware becomes TransientServerError like the default response path, rather than a generic TransportError that read as a legacy rejection. - tools/call is re-issued too. Changelog major change 9 states the rule with no per-method exception, and this revision makes closing the response stream the cancellation signal the server MUST act on, so the replacement request is what the protocol expects rather than a blind replay. Exactly one re-issue happens: with_retry still refuses to retry a non-idempotent method. Every other no-replay guarantee is unchanged. - On a modern server a break landing inside an SSE event's JSON now takes the same re-issue path as a break between events, matching ServerHTTP. The saw_invalid_json TransportError stays for legacy sessions. - A modern verdict raises the new Errors::ModernServerError, which MCPClient.connect re-raises for an ambiguous URL instead of falling through to the legacy SSE and HTTP+POST transports; protocol: :modern likewise no longer falls back to those legacy-only transports. - The modern verdict is cached like the legacy one, so a server once found modern never gets initialize on a later connection. New examples in streamable_http_modern_2026_verify_spec.rb pin each fix against both HTTP transports; every one of them fails if its fix is reverted. A mutation pass over the existing modern spec also found three assertions that did not pin the behaviour they name — the GET events stream (the assertion raced the thread that opens it), discover_timeout, and the DiscoverResult shape check — now pinned too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A branch stacked above this one derives the Mcp-Param-* headers from the tool list, so a tools/call there fetches tools/list before it sends. These examples scripted only the methods this branch issues, so they failed with "unexpected method tools/list" the moment they ran on that stack — a failure about the fixture, not about the recovery they pin. The stub now answers tools/list with an empty list unless an example scripts it. Nothing on this branch asks for it, so the default is inert here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Review round 4. Every broken-stream example so far returned a *completed* HTTP response whose SSE body carried no result. That is not what a broken response stream looks like on the wire: Faraday raises rather than handing back a truncated body, so a socket that died mid-body never reached the SSE parser at all and became a plain ConnectionError with no replacement request — the one thing MCP 2026-07-28 requires (changelog, major change 9). A local TCP server that writes SSE headers, one chunk and then closes without the terminating chunk now drives the re-issue path for both HTTP transports, for tools/call and for the server/discover probe. - Classify Faraday::ConnectionFailed by its wrapped exception: EOF, reset, aborted, broken pipe and a truncated response head are a lost response stream on a modern connection; connection refused, DNS and unreachable network are not (nothing was in flight), and neither is a notification. - Bound the replacement to one request for every method. with_retry treated ResponseStreamClosedError as a generic transport error, so an idempotent method made 2 x (retries + 1) attempts where the code claimed one re-issue. - Raise ModernServerError, and cache the modern era, when discovery identifies a modern server but no version is mutual — a DiscoverResult advertising only unknown versions, or a well-formed -32022. Both fell through MCPClient.connect to the legacy SSE and HTTP+POST transports, burying the actionable message, and left the era unsettled so a later connect could send initialize. - Answer a legacy server's request on ServerHTTP's new SSE path: ping gets the empty result 2025-11-25 requires, anything else gets -32601 instead of silence that leaves the server waiting. Modern streams still drop them. - Stop accepting a lone response whose id is not the one asked for on a modern stream (both transports): no response to this request arrived, so the stream was lost. Legacy servers keep the lenient fallback. - Serialize ensure_connected across its check and the reconnect that follows, so a caller that observed a dead connection cannot tear down the connection another caller established in the meantime. New coverage: real mid-stream socket close for both transports and both the probe and tools/call, the one-replacement bound under a retry budget, incompatible discovery through MCPClient.connect and across reconnects, a modern server that offers an Mcp-Session-Id anyway, legacy ping and unknown server requests on a plain-HTTP SSE stream, wrong-id-only streams, the stale-reconnect race, and full notification payloads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Round 4 review follow-ups for modern Streamable HTTP.
The re-issue rule only reached streams that broke as Faraday::ConnectionFailed
wrapping a small allowlist of socket errors. Production Streamable HTTP is
HTTPS with gzip, and neither lands there: the net_http adapter raises
Faraday::SSLError — a *sibling* of ConnectionFailed, not a subclass — for a TLS
session that dies mid-read, and a gzip body cut before its footer surfaces from
the decompressor. Both now take the one re-issue path, along with a generic
IOError ("closed stream"). A TLS handshake that never completed still does not:
the request never left the client, so there is nothing in flight to replace,
and that is now pinned by counting connections rather than by inspecting the
exception connect returns.
The mirror image was worse. Faraday discards a partially read body and raises,
so a socket that died *after* the final SSE event looked exactly like one that
died before it, and a tools/call the server had already executed was sent
again. Response bodies are now streamed into a per-request buffer by an
innermost Faraday middleware, so the bytes that arrived survive the failure: if
they carry this request's complete answer, that answer is returned (or its
JSON-RPC error raised) instead of a replacement request going out. A final
event whose terminating blank line never arrived was never dispatched and does
not count as delivered.
That same buffer bounds discovery. discover_timeout set only Faraday's socket
timeout, which measures the gap between reads, so a probe answered with an
endless drip of SSE keep-alives never timed out and blocked every caller
waiting on the connection monitor; one deadline now covers the probe and its
re-issue, checked as the body arrives.
Also: both SSE parsers now treat CRLF, CR and LF alike, so bare-CR framing is
read rather than mistaken for a stream that delivered nothing; a tolerated
discovery 404 is tolerated on every connect rather than turning into a failure
on the second, because it was checked after the cached modern verdict;
protocol: :modern outranks the /sse URL suffix, which used to select the
legacy-only SSE transport and silently drop the option; and teardown of a
modern connection never sends DELETE, whatever ended up in @session_id.
x-mcp-header / Mcp-Param-* derivation, dropping invalid annotated tools from
tools/list, and HeaderMismatch refresh-and-retry are implemented on the branch
stacked directly above this one; both reviewers flagged them because they only
saw this diff.
New coverage, each confirmed to redden under the mutation it exists for: TLS
and plaintext mid-body breaks against a real listener, a delivered response
followed by a dead socket, an unterminated final event, a truncated gzip body,
bare-CR framing, the discovery deadline, timeout tearing the stream down (with
no cancellation notification), a notification staying out of response-stream
recovery, a legacy tools/call never being re-POSTed, the re-issued call keeping
its original arguments and mirrored headers, two concurrent recoveries not
crossing their arguments, clientCapabilities and clientInfo on the HTTP wire,
and connection counting for both "never established" cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Two fixtures below counted what this branch changed. The HTTP handshake example expected `initialize` alone; the modern probe now runs first and is refused, which is why the handshake answers at all, so the example pins both. The metadata example counted one evaluation per outgoing request, but the legacy handshake's notifications/initialized goes straight to stdin rather than through send_request: it is an outgoing message built like any other, so the count is per message now, which is what the assertion always meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
simonx1
force-pushed
the
mcp-2026/streamable-http-modern
branch
from
September 5, 2026 19:00
193e171 to
00ddfdf
Compare
…e the era verdict on status Fourth review round on Streamable HTTP modern mode. - Every request now gets an overall deadline enforced while the body arrives (MCP 2026-07-28: "SHOULD always enforce a maximum timeout regardless of progress"), not only the server/discover probe: a tools/call with `timeout:` on a stream dripping keep-alives faster than the socket timeout timed out never. The probe's re-issue gets the time left on the discovery deadline (socket timeout clamped) instead of a fresh allowance. - ServerHTTP reads SSE response streams incrementally: each complete event is dispatched while the response is open, so a legacy server's ping is answered (and a progress notification delivered) before the server has to end the response — a server waiting for its ping to be answered before sending the result deadlocked against the old answer-at-EOF client. The completed body is parsed once more for the response only; events already handled live are not dispatched twice. - A completed HTTP response whose final SSE event lacks its terminating blank line delivered nothing for it: on a modern server the event is dropped and a missing response re-issued (both transports), matching what the salvage path already did for broken sockets. Legacy keeps the lenient parse. - A stream that stalls after the final event until the timeout keeps the delivered answer instead of raising a timeout for a settled request. - Era detection follows the HTTP-status rule: a reserved modern error settles the era only in a 400 body (plus 404/-32601); the same body under 200 or 405 is a legacy answer. A reconnect probe that fails inconclusively after a cached modern verdict is reported as that failure, not as ModernServerError. - A completed but corrupt gzip body is a TransportError, not a broken stream to re-issue. - MCP-Protocol-Version is taken from the request body's _meta rather than the transport's current version. - Coverage: live-socket examples for the ordinary-request and heartbeat deadline, the remaining discovery deadline, the delivered-then-stalled stream and the live ping/notification dispatch (per-connection fixture threads, DELAY/STALL/DELIVER_THEN_STALL/EVENT_THEN_WAIT tokens); the era-by-status matrix, raise_error 400, unterminated final events, corrupt gzip, cached-modern reconnect, tasks/* Mcp-Name on the wire, unicode resources/read URI, invalid modern log level, suppressed notifications/initialized, a legacy session through auto negotiation; the timeout-configuration, concurrent-recovery (barrier + global id uniqueness), legacy-GET and _meta examples now prove what they claim. - SseEventScanner and StreamCapture extracted under lib/mcp_client/http_transport_base/ (module length). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Third PR of the MCP 2026-07-28 series (stacked on #225). Brings the Streamable HTTP transport (and the plain HTTP transport) to the stateless 2026-07-28 shape while keeping full legacy compatibility.
server/discoveris POSTed first.DiscoverResult⇒ modern; a recognized modern JSON-RPC error in a 400 body ⇒ modern (-32022retried with an advertised version,-32020/-32021surfaced, never a fallback); 404 +-32601⇒ modern server without discovery (tolerated); any other 4xx or a non-DiscoverResult2xx ⇒ legacy:initializeruns exactly as before and the verdict is cached. 401/403, 5xx and timeouts propagate.protocol: :auto|:modern|:legacyanddiscover_timeout:on both HTTP transports, config builders, factory andMCPClient.connect.MCP-Protocol-Version(must equal the body's_meta, kept in sync across inline version retries),Mcp-Method, andMcp-Namefromparams.name/params.uri(andtaskIdfor the tasks extension methods), with the=?base64?…?=sentinel encoding for non-header-safe values (JsonRpcCommon#encode_header_value).Mcp-Session-Id,Last-Event-ID, DELETE, or open the GET stream. A response stream that ends without the response is re-issued with a new id for idempotent methods;tools/callraises instead (it may already have executed — the host decides). Closing the stream is the cancellation signal, so nonotifications/cancelledon timeout.ping→server/discover,log_level=→ per-request_metalog level.x-mcp-header(Mcp-Param-*) and theHeaderMismatchrefresh-and-retry are PR 4;subscriptions/listenreplaces the GET stream in PR 6.Test plan
bundle exec rspec— 1841 examples, 0 failures (36 new instreamable_http_modern_2026_spec.rb)bundle exec rubocop— clean🤖 Generated with Claude Code
https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7