Skip to content

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
mcp-2026/stateless-stdiofrom
mcp-2026/streamable-http-modern
Open

MCP 2026-07-28 (3/11): Streamable HTTP modern mode — no sessions, request metadata headers, era detection#226
simonx1 wants to merge 9 commits into
mcp-2026/stateless-stdiofrom
mcp-2026/streamable-http-modern

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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.

  • Era detection (basic/transports/streamable-http "Backward Compatibility") — server/discover is POSTed first. DiscoverResult ⇒ modern; a recognized modern JSON-RPC error in a 400 body ⇒ modern (-32022 retried with an advertised version, -32020/-32021 surfaced, never a fallback); 404 + -32601 ⇒ modern server without discovery (tolerated); any other 4xx or a non-DiscoverResult 2xx ⇒ legacy: initialize runs exactly as before and the verdict is cached. 401/403, 5xx and timeouts propagate. protocol: :auto|:modern|:legacy and discover_timeout: on both HTTP transports, config builders, factory and MCPClient.connect.
  • Request metadata headers (changelog minor How to use/register the MCP server with OpenAI using this gem? #4, "Standard Request Headers") — MCP-Protocol-Version (must equal the body's _meta, kept in sync across inline version retries), Mcp-Method, and Mcp-Name from params.name / params.uri (and taskId for the tasks extension methods), with the =?base64?…?= sentinel encoding for non-header-safe values (JsonRpcCommon#encode_header_value).
  • No protocol-level session / no GET / no resumability (changelog major Documentation: Show use of a server_definition.json file #1, Timeout error using Neon MCP server via stdio config with MCPClient #9) — modern connections never send 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/call raises instead (it may already have executed — the host decides). Closing the stream is the cancellation signal, so no notifications/cancelled on timeout.
  • Response streams — request-scoped notifications still flow; server-initiated requests on a response stream (forbidden in 2026-07-28) are dropped with a warning; SSE comment keep-alives are ignored.
  • Removed methodspingserver/discover, log_level= → per-request _meta log level.

x-mcp-header (Mcp-Param-*) and the HeaderMismatch refresh-and-retry are PR 4; subscriptions/listen replaces the GET stream in PR 6.

Test plan

  • bundle exec rspec — 1841 examples, 0 failures (36 new in streamable_http_modern_2026_spec.rb)
  • bundle exec rubocop — clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from 7aac44b to 34e044a Compare September 1, 2026 19:07
@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from f5bc6d6 to 39ae5ba Compare September 1, 2026 19:55
@simonx1
simonx1 force-pushed the mcp-2026/stateless-stdio branch from 7728efb to 9252e81 Compare September 5, 2026 00:17
@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from 0e00bfa to 725e88f Compare September 5, 2026 00:19
@simonx1
simonx1 force-pushed the mcp-2026/stateless-stdio branch from 255bda5 to 7e7b46a Compare September 5, 2026 12:31
@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch 2 times, most recently from 2a640ac to 193e171 Compare September 5, 2026 18:46
@simonx1
simonx1 force-pushed the mcp-2026/stateless-stdio branch from f851b0f to a4cfc1a Compare September 5, 2026 18:56
simonx1 and others added 8 commits September 5, 2026 20:56
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
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from 193e171 to 00ddfdf Compare September 5, 2026 19:00
…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
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.

1 participant