## Motivation and Context
"listen keepalive is not started when the interval is nil" fails intermittently on CI. The most recent run failed
on Ruby 3.2 alone while 3.1 and head passed, and a re-run of the same commit went green.
The assertion samples a process-wide count:
```ruby
before = Thread.list.size
open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
assert_equal before, Thread.list.size, "a nil interval must not spawn a keepalive thread"
```
`Thread.list` covers the whole process, so the count moves for reasons this test has no interest in.
The observed failure reported 54 against 51: three threads had *gone away* between the samples, not appeared.
This file starts threads in sixteen places, and the SSE stream and reaper threads they leave behind finish whenever
they finish, so whether the count holds still depends on the test order and on the scheduler rather than on
the behavior under test.
The test itself is asking the right question. `setup` builds its transport with `listen_keepalive_interval: nil`
precisely so opening a listen stream spawns no timer, and the point is to hold that guarantee. Only the measurement is wrong,
so this compares the set of threads instead: a thread that disappears no longer registers, and a thread that appears still does.
Minitest runs these tests serially and `open_listen_stream` drives the SSE body inline rather than on a thread,
so a keepalive timer is the only thread that can appear across the two samples.
No library code changes.
## How Has This Been Tested?
The rewritten assertion was checked for sensitivity before being kept: a temporary probe pointed `open_listen_stream` at
a transport built with `listen_keepalive_interval: 15` and asserted the set difference was *not* empty. It passed,
confirming the assertion still catches a spawned keepalive thread rather than merely never failing. The probe was
removed afterwards.
The failure did not reproduce locally beforehand, across six seeds of the file on its own and six full-suite runs,
which matches a timing-sensitive flake that needs a slower machine.
## Breaking Changes
None.
## Motivation and Context
On graceful teardown (`transport.close`), each open `subscriptions/listen` stream receives
its `SubscriptionsListenResult` response before closing, signaling a clean end the client can distinguish
from an abrupt disconnect. But a delivery that snapshotted the stream from the registry
before teardown cleared it performs its write outside the lock, so a change notification could land after the final result.
Each subscription entry now carries a per-stream write mutex. Teardown marks the entry closed and writes
the result under that mutex; a racing delivery either lands before the result or observes `closed` and skips,
keeping the graceful result the stream's final message. The mutex is per stream and held only around the single write call,
so deliveries to different streams stay as parallel as before, and no path nests it inside the transport mutex
(delivery snapshots release `@mutex` first, and the write-error cleanup takes `@mutex` only after the write mutex is released),
ruling out lock-order inversions. Keepalive pings stay outside the mutex: they are SSE comment frames, which clients ignore by
specification, so one landing after the result is harmless.
## How Has This Been Tested?
With a new regression test pinning the race (an entry captured before teardown and re-presented to delivery afterwards writes
nothing after the result), the full suite, RuboCop, and the conformance suite, all green.
## Breaking Changes
None. The write mutex is internal to the listen registry; the wire only gains the ordering guarantee.
## Motivation and Context
The documented Rails controller pattern builds a fresh transport per request and renders `body.first`,
but a modern client's `subscriptions/listen` made `handle_request` return the streaming `Proc` body,
so exactly the documented configuration answered every notification-stream request with a 500 - quietly,
since tool calls and listings kept working. Nothing gated the route: `serves_subscriptions_listen?` was
hardcoded `true` and consulted only by `Server#discover` for capability stripping,
and the modern path deliberately ignores `stateless:`.
`StreamableHTTPTransport.new` now accepts `serve_subscriptions_listen:` (default `true`, behavior unchanged).
When `false`, the transport skips the listen interception so the method falls through the dispatcher as
unimplemented - the spec's 404 with JSON-RPC `-32601`, in an Array body the controller pattern can render - and
`serves_subscriptions_listen?` reflects the setting, so `Server#discover` stops advertising the `listChanged`/`subscribe`
capability flags through its existing wiring and the advertisement agrees with the actual behavior.
The documented controller example passes the flag and explains why; refusing implicitly in `stateless:` mode
was rejected because a stateless transport mounted as a long-lived Rack app serves listen streams correctly
(listen delivery deliberately precedes the stateless notification guard).
On the default streaming side, the listen body is now a small streaming-body object rather than a bare `Proc`:
it still responds to `call` and deliberately not to `each` (Rack would otherwise classify it as enumerable
and break the streaming), while `first` raises an error naming `serve_subscriptions_listen: false`
and pointing at the transports documentation, so a host that buffers the body the documented way fails with
guidance instead of a bare `NoMethodError`.
Fixes#531.
## How Has This Been Tested?
With the issue's reproduction script (default: a streaming body whose `body.first` raises the guidance;
with the flag: 404, `application/json`, and an Array body whose first element is the `-32601` error),
new transport tests covering the refusal shape, the capability stripping on `server/discover`, and the guidance
raise with the body's streaming classification, the full suite, RuboCop, and the conformance suite, all green;
the docs build passes every internal link and anchor check.
## Breaking Changes
None. The keyword defaults to the current behavior; refusal is opt-in.
## Motivation and Context
Per SEP-2575, the server MUST NOT send any notification on a subscription before `notifications/subscriptions/acknowledged`.
The listen stream was registered in the delivery registry before its acknowledgement was written (the write happens outside
the lock so a stalled reader cannot block the transport), so a change notification published concurrently from another thread
could be written to the stream ahead of the acknowledgement.
The entry is now inserted inactive - still reserving the id and the cap slot atomically - and becomes eligible for delivery only
after the acknowledgement write has completed. A concurrent notification inside the window skips the inactive entry,
which is indistinguishable from the change happening just before the subscription opened.
## How Has This Been Tested?
With a new regression test pinning the window (an entry injected in the registered-but-unacknowledged state receives nothing until activation),
the full suite, RuboCop, and the conformance suite, all green.
## Breaking Changes
None. Activation happens synchronously inside the same response body proc, so an acknowledged stream behaves exactly as before.
## Motivation and Context
A request id is the only key that routes request-scoped messages back to the request that caused them:
progress and log notifications, server-to-client requests such as sampling and elicitation,
and `notifications/cancelled` all resolve through it. Nothing checked that the id was free,
so a second request arriving under an id already in flight took the routing entry over.
From that point the first request's notifications were written to the second request's SSE stream,
and whichever finished first removed the shared entry, leaving the survivor's messages nowhere to go.
The spec puts the uniqueness obligation on the sender, and 2026-07-28 draws it at exactly
the window this change enforces: "The request ID MUST NOT match the ID of any other request the sender
has issued and not yet received a response for". Earlier revisions worded it as never reusing an id within
the session. Neither says what a receiver does with a duplicate, but the Streamable HTTP rules require
that messages the server sends before the response "SHOULD relate to the originating client request",
and two live requests sharing one id make that impossible to honor for either of them.
Answering the second one is the only option that stays correct, so it is refused the same way
a duplicate `initialize` is, and for the same reason: a repeated id must not silently displace state
the first one established.
The reference SDKs currently accept the duplicate and let the newer request take the entry.
That is not a settled design: the Python SDK carries a TODO naming rejection with `INVALID_REQUEST`
as the revisit, so this moves toward where they expect to end up rather than away from them.
`ServerSession#register_in_flight` now claims the id atomically and reports a collision instead of overwriting,
which `Server` turns into an Invalid Request for every transport. `StreamableHTTPTransport` refuses
the colliding POST with 409 before it registers a stream, since the stream is registered ahead of dispatch
and would otherwise take over the routing entry for as long as the rejection takes.
Removal is now guarded by identity everywhere the registry is keyed by request id, so a request that loses
a race cannot retire a registration it does not own. `initialize` never registers, so its `ensure` no longer unregisters:
a refused duplicate `initialize` reusing an in-flight id used to evict that registration on the way out,
silently disabling cancellation for the request that owned it (Streamable HTTP refuses such a POST before dispatch;
stdio reached this path). `drop_broken_stream` had a sharper form of the same bug: it removed whichever stream held
the id while closing the one passed in, which are not necessarily the same stream.
Only ids that are in flight are refused, which is the scope the current spec draws.
Enforcing the older "never within the session" wording would instead mean remembering every id a session ever used,
and the in-flight window is the part routing depends on either way. Sequential reuse keeps working,
and neither this SDK's client (`SecureRandom.uuid`) nor a client built on the TypeScript SDK (a per-connection counter)
can produce a collision.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` reproduce the collision:
a tool parked mid-request, a second POST reusing its id, and assertions that the second POST is refused
and that the first request still receives the progress frame it emits afterwards. Others cover
the identity-guarded removal and confirm that an id can be reused once the earlier request has finished.
New tests in `test/mcp/server_cancellation_test.rb` cover the registry directly, including
the transport-independent Invalid Request and the survival of an in-flight registration across
a refused duplicate `initialize` under the same id. All of them except the sequential-reuse guard fail
without this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
A request whose id is already in flight on the same session is now answered with Invalid Request
(HTTP 409 on Streamable HTTP) instead of being processed. A client that reuses ids concurrently was
already violating the specification and was already having its notifications misrouted.
## Motivation and Context
Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761
A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through
the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are
both gated on the per-request `_meta` envelope, which such a connection never carries,
so strictly validating clients reject every result as missing the REQUIRED `resultType`.
The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of
the protocol version itself: legacy versions establish a session via `initialize`
(`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with
no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope
is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state
the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates
from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry;
the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too,
and counter-offers `LATEST_HANDSHAKE_VERSION`.
`initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list
(`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know,
is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on
`2025-11-25`, where `resultType` does not exist and its absence is the correct shape,
resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and
the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are
intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is
no longer constructible, so the gates are correct by construction.
The client side is symmetrized the same way, matching both reference SDKs:
the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default,
passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError`
(validated before the stdio transport spawns its child process), and a server answering `initialize` with
a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to
the discovery probe and never downgrades it into the legacy fallback: the probe's failure is
the real answer and propagates, instead of the fallback dying on the handshake guard.
The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`),
and the handshake list is derived from the stable and modern lists so the era partition cannot drift
when the next revision lands.
Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect
on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land
on a deprecating version and the call site the warnings used to sit on had become unreachable.
On the server the answer is removal: `Configuration#protocol_version` scopes the handshake,
so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction.
Accepting one would configure nothing (a modern version has no handshake to pin),
and neither reference SDK carries a config value that is accepted but inert:
Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions`
is consumed by its own era's mechanism. With no path left onto a deprecating revision,
the server-side deprecation warning plumbing became unreachable and is removed along with it.
Keying those warnings on a modern-era session instead was tried and rejected,
because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line
would then carry a deprecation warning for doing exactly what the revision prescribes
(a single test file produced over a thousand of them).
A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered,
and such a request continues to be served at the negotiated revision rather than rejected.
Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule
covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`,
through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with
its consequence, which is that no `resultType` appears on those results.
Two details follow from the pin being handshake-scoped. Reading it back when nothing is set
now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era,
so the getter no longer hands out a value its own writer rejects and round-tripping
the setting keeps working; only `Server#validate!` reads that default,
through three comparisons that answer the same for either value, so nothing else moves.
And the rejection names its reason instead of listing the accepted versions,
because this is the error an upgrade from a release that accepted the value lands on,
which makes it the migration note a bare list would not be.
`MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now,
and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be
a breaking change for no gain.
The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper
now use the handshake version as well, and the README section on `protocol_version` describes the pin as
handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default,
and `2026-07-28` reached through `server/discover` with no configuration needed.
## How Has This Been Tested?
New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is
counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType`
(the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer,
the `ArgumentError` for an explicitly modern version on the legacy connect
(asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult`
on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of
falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.
Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for
clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version,
the answer is an older one, and the connection speaks the answer afterwards
(over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption
from an echo, because every one of them had the server answer with the version the client had just offered.
Both new tests were confirmed to fail when the client is made to report its own offer instead.
Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked:
the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer,
and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires.
The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version,
or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths,
since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests
on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.
The default-pin test now also round-trips what it reads back into a fresh `Configuration`,
which is what the old default could not do. The default move was checked for neutrality outside
the suite as well, by building a server that uses every feature `Server#validate!` gates on that value
(`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations)
and confirming all of them are still accepted.
## Breaking Changes
Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through
the handshake are counter-offered `2025-11-25` and are served at that revision, matching
the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now
raises `ArgumentError` (the pin scopes the handshake, which cannot land there);
the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway.
Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`;
nothing in this SDK behaves differently for either value, but code comparing that reader against
a literal will see the change. The client no longer offers modern versions on the legacy handshake
and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features
no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision;
the client-side warning on a modern connect replaces them.
## Motivation and Context
The modern lifecycle removes server-initiated JSON-RPC requests: a stateless server has no connection to send them on,
and SEP-2322 multi round-trip `input_required` results replace what sampling and elicitation covered before.
`StdioTransport#send_request` already says so, refusing outright when its session is modern.
`StreamableHTTPTransport#send_request` says nothing, so a modern handler that reaches for elicitation
or sampling falls through to the session lookup and is told `Session not found: <uuid>` against a session id it never chose.
Failing is right, the explanation is not: `handle_modern` mints a session per request without ever registering it in `@sessions`,
so that lookup cannot succeed for a modern request no matter what the handler does.
`send_request` now refuses a modern-era session up front with the message `StdioTransport` uses, which turns
an incidental registration detail into a stated rule and leaves the rest of the method a legacy-only path by construction.
Callers see the same `RuntimeError` class as before, only with a message naming the actual constraint.
## How Has This Been Tested?
A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` sends `elicitation/create` from
a session constructed with `era: :modern` and asserts the SEP-2575 message. It sits with the existing guard tests for stateless mode,
a missing `session_id`, and an unknown session, which still pass unchanged.
`bundle exec rake` is green, and both conformance legs pass their baseline check.
## Breaking Changes
None. The call already failed; only the message changes.
## Motivation and Context
The specification asks implementations to establish timeouts for all sent requests,
"to prevent hung connections and resource exhaustion", to cancel the request once the deadline passes,
and to let SDK users configure the deadline per request. This SDK did none of the three: `send_request` blocked
on `Queue#pop` with no deadline, so a client that opens a session, triggers a handler that elicits input,
and then simply never answers holds that worker thread for good. A handful of such requests exhausts
a Rack server's thread pool. The existing escape hatches are weak: the idle reaper waits 1800 seconds
by default and refreshes on any traffic for the session, so a client that keeps sending other requests never trips it,
and only the GET stream has a keepalive to notice a vanished peer.
`StreamableHTTPTransport` now waits `server_to_client_request_timeout:` seconds
(`DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT`, 600) for a response, and `timeout:` overrides it per request through
`ServerContext` and `ServerSession` down to the transport. The option spells out its direction because
the same constructor already takes `max_request_bytes:` for the requests arriving the other way.
Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for
the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default is wrong".
Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's
own model producing a sample. The Python SDK leaves the deadline unset and bounds nothing by default.
On expiry the transport sends `notifications/cancelled` for the request it stopped waiting on, so a late answer
does not act on something the server abandoned, and raises the new `MCP::Server::RequestTimeoutError`.
Both reference SDKs send the same courtesy cancel on timeout, and it is what the revision governing this path asks
for: 2025-11-25 lets either side cancel and tells the sender to "issue a cancellation notification for that request
and stop waiting". Only that revision and earlier reach the wait, since the modern lifecycle forbids these requests
and its sessionless requests never register the session `send_request` looks up.
Uncaught in a handler, the error answers the client's originating request with `-32001` rather than a generic
internal error. The code is not spec-allocated, but it sits in the implementation-defined server range and is what
the Python SDK reports for this condition, so a peer that recognizes it there reads the same meaning here.
The wait itself moves from `Queue` to a small `MCP::Server::PendingResponse`: `Queue#pop` only accepts a `timeout:`
on Ruby 3.2 and later and this gem supports 2.7, so the wait is a `ConditionVariable` with a monotonic deadline.
It keeps the `push`/`pop` names, leaving the three resolving call sites (a client response, a cancellation, session teardown)
unchanged, and keeps first-writer-wins so a cancellation racing a real response still cannot overwrite it.
The expiry block runs after the lock is released, since it cancels the request and would otherwise re-enter
the same non-reentrant mutex.
stdio is untouched and ignores `timeout:`, which the README now states. Its `send_request` waits inside
a `$stdin.gets` loop rather than on a queue, and the process model differs enough (the server owns the client
process, whose exit surfaces as EOF) that it deserves its own change; `forward_to_transport` already filters
`timeout:` out for transports that do not declare it, so custom transports and stdio keep their existing signatures.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover a request timing out with
the transport default, `timeout:` overriding it per request, the error code it reports, expiry reaching the peer
as `notifications/cancelled`, a response arriving before the deadline still returning normally, and
the constructor rejecting a non-positive `server_to_client_request_timeout`.
`test/mcp/server_session_request_timeout_test.rb` covers the deadline reaching the transport from all five
`ServerSession` entry points, and being dropped for transports that never declared it.
`test/mcp/server_context_test.rb` covers the same through `ServerContext`, including an omitted `timeout:`
not being forwarded at all. The pre-existing `send_request` tests (the response path, cross-session rejection,
cancellation, the cancel/response race, client errors, and session teardown) pass unchanged against the new
wait primitive.
`bundle exec rake` is green, and both conformance legs pass their baseline check. `PendingResponse` was exercised
directly on Ruby 2.7.0, the minimum this gem supports and the reason the wait avoids `Queue#pop(timeout:)`:
the deadline fires on time, a pushed value is delivered, the first writer wins, and the expiry block can resolve
the same object without deadlocking.
## Breaking Changes
Server-to-client requests that previously waited forever now fail after ten minutes by default. This is
the incompatible-but-required kind of change VERSIONING.md admits into a minor release: the unbounded wait deviates from
the specification and is the resource-exhaustion path it exists to prevent. Handlers that legitimately wait longer pass
a larger `timeout:`; the release notes and CHANGELOG entry need to call this out and point at that knob.
## Motivation and Context
SEP-2575 removes `initialize`, `ping`, `logging/setLevel`, `resources/subscribe`,
and `resources/unsubscribe` from the modern lifecycle, and the server answers them with `-32601` there.
The check consulted `ServerSession#era` alone, but `StdioTransport` locks the era in `lock_modern_era_on_success`,
which runs after a response is produced. The first frame of a stdio connection therefore reached
the server with `era == nil`, and a request carrying the modern `_meta` envelope was served instead of refused.
All five methods leaked, one request per connection:
```
initialize a full InitializeResult
ping {resultType: "complete"}
logging/setLevel {resultType: "complete"}
resources/subscribe {resultType: "complete"}
resources/unsubscribe {resultType: "complete"}
```
`initialize` is the worst of the five because it also mutates the session: serving it negotiated
the legacy lifecycle for a request that had declared the modern one, pinning the connection to
`:legacy` and marking it initialized. The Streamable HTTP transport was never affected,
since it routes sessionless modern traffic to `handle_modern` and rejects a modern envelope on every other path.
The era is the wrong thing to consult on its own: a request declares which lifecycle it belongs to
through its own envelope, independently of connection state that settles later. The Python SDK gates
on exactly that, testing each request's `_meta` before dispatch rather than a negotiated connection
mode. `modern_request?` now does the same here, treating a locked `:modern` era as authoritative and
falling back to the request's envelope while the era is still undetermined. A legacy-locked session is left alone:
`lift_request_envelope` already answers a modern envelope there with the lifecycle violation,
which names the cause better than Method not found.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover all five removed methods being refused with `-32601` when
they carry the envelope on an era-less session, an enveloped `initialize` no longer locking the era
or marking the session initialized, a method the modern lifecycle keeps still being served under the same
conditions, and a legacy `initialize` negotiating 2026-07-28 still succeeding and locking `:legacy` - negotiating
a dual-era version through the handshake is not the same as carrying the envelope. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives the case end to end,
sending an enveloped `resources/subscribe` as a connection's first frame.
One existing assertion changed. "modern results carry resultType complete" used `ping` as one of its
stamped examples, which only passed because of this defect; `ping` is absent from the 2026-07-28 schema,
so a modern request naming it is refused rather than stamped. It now uses `prompts/list`, with a note
on why `ping` cannot appear there. The existing legacy counterpart, which pings without an envelope,
is unchanged and still expects no stamp.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance suite exercises
the HTTP transport, which this does not change, so its baseline is unaffected.
## Breaking Changes
None for a conforming client. A request that both carries the 2026-07-28 envelope and names a method
that revision removed now receives `-32601` instead of a result, which is the answer the spec already
required once the era was known.
## Motivation and Context
Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream;
`subscriptions/listen` replaces it as a long-lived POST that opts in to server change notifications.
The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035);
the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321.
Wire behavior, per the draft schema:
- The request carries a REQUIRED `notifications` filter (`SubscriptionFilter`): `toolsListChanged`,
`promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (URI list, replacing
the legacy `resources/subscribe` RPC). Every type is opt-in; the server MUST NOT send types
the client did not request.
- The first stream message is `notifications/subscriptions/acknowledged`, reporting the subset of
requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery
(`listChanged`, `subscribe`), the same derivation `server/discover` uses for its era-aware capability stripping;
the mere presence of a primitive's capability is not enough.
- Every notification delivered on the stream carries the correlating `io.modelcontextprotocol/subscriptionId`
(the listen request id) in `_meta`.
- A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` response before closing the stream,
stamped with the REQUIRED 2026-07-28 `resultType` at its construction site (it never passes through the dispatch path);
an abrupt disconnect sends nothing.
Implementation:
- `StreamableHTTPTransport` intercepts `subscriptions/listen` on the modern path (after header and envelope validation)
and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream.
Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks into `send_notification` ahead of
the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers.
The matching snapshot is taken under the transport mutex, but stream writes happen outside it,
matching the legacy delivery paths: a slow or stalled subscriber must not block the transport.
Duplicate subscription ids close the new stream instead of double-registering.
- Concurrent listen streams are capped (`max_listen_subscriptions:`, default 1000, `nil` to opt out);
a listen request past the cap is rejected with HTTP 503, like the `max_sessions` guard against session floods.
Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain
unbounded connections.
- Each stream is kept alive by an SSE comment frame written every `listen_keepalive_interval:` seconds
(default 15, matching the TypeScript SDK; `nil` to opt out when an upstream proxy pings the stream).
A silently dropped connection would otherwise hold its capped slot until the next fan-out write failed,
so on a quiet server the cap would ratchet down permanently; the periodic write detects the dead peer
and frees the slot. A comment frame cannot corrupt an interleaved notification's JSON, and the write
happens outside the transport mutex, reusing the legacy GET stream's keepalive mechanism.
- Every SSE response now carries `x-accel-buffering: no`, which the spec asks of SSE streams and both
reference SDKs send: a buffering reverse proxy would hold events back instead of delivering them as
they are written, and on a listen stream it would also swallow the keepalive frames a dropped peer
is detected by. The header rides the shared `SSE_HEADERS`, so the legacy streams gain it too.
- stdio does not serve the stream, matching the Python SDK's stream-pair behavior: no server handler is registered,
so the method answers `-32601`.
- `Server#discover` becomes era-aware about notification delivery: `listChanged`/`subscribe` capability flags
promise delivery over `subscriptions/listen` streams in the modern lifecycle, so they are stripped when
the transport does not serve that RPC (the new `Transport#serves_subscriptions_listen?` seam, true for `StreamableHTTPTransport`).
- The conformance fixture defines the diagnostic triggers the `server-stateless` scenario calls
(`test_trigger_tool_change` / `test_trigger_prompt_change`): each broadcasts its list-changed notification
to the listen streams and returns, mirroring the suite's TypeScript reference fixture, which mutates nothing either.
Out of scope, noted for follow-ups: an external event bus for multi-worker deployments, and the client-side listen driver
(the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047).
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and
for capability entries lacking the delivery flag), the `notifications` filter and envelope requirements (400 responses),
opt-in-only delivery with the correlating `subscriptionId`, URI-scoped `resources/updated` delivery, per-subscription ids across
concurrent streams, the graceful close result carrying `resultType: "complete"`, duplicate-id rejection, the 503 past
the concurrent stream cap, the keepalive (writing the comment frame outside the mutex, freeing a dead peer's slot,
no thread when the interval is `nil`, and the positive-or-nil interval validation), and `x-accel-buffering: no`
on both the listen stream and the legacy GET stream.
`test/mcp/server/transports/stdio_transport_test.rb` asserts `-32601` over stdio,
and `test/mcp/server_test.rb` covers the era-aware capability stripping in `server/discover` for both transport kinds.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
Against the conformance fixture server at `--spec-version 2026-07-28`, the `server-stateless` subscription checks
all report SUCCESS: the acknowledgement, `subscriptionId` tagging, and filter-containment MUSTs, plus both
list-changed SHOULD checks driven by the new trigger tools. The `--requirements 2025-11-25` server leg
passes 78/78, unchanged.
## Breaking Changes
None. The method was previously unhandled (`-32601` everywhere); the only observable change to existing responses is that
`server/discover` no longer advertises `listChanged`/`subscribe` flags on transports that cannot deliver those notifications in
the modern lifecycle, which `server/discover` has not shipped in a gem release with anyway.
## Motivation and Context
The 2026-07-28 conformance requirements reject two admission gaps in the modern (stateless) lifecycle
that the current implementation answers with the wrong signal:
- The RPCs the modern lifecycle removes (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe`)
were still dispatched, or answered incidental errors (`initialize` answered `-32022`, `resources/subscribe` a capability error);
the requirement is `-32601` Method not found with HTTP 404.
- `server/discover` was fully envelope-exempt, while the requirements validate its envelope
like every other modern request.
The per-request `_meta` validation itself (`-32602` naming the offending keys, optional `clientInfo`,
era classification on the reserved `protocolVersion` key alone) landed separately in #491;
this change builds on that shape and completes the admission rules:
- `Server#handle_request` rejects `Methods::MODERN_REMOVED_METHODS` on a modern-era session
with `-32601` before the capability check, since the method does not exist in that era at all.
The Streamable HTTP modern path maps `-32601` to 404 and `-32602` to 400 through its existing status ladder.
- The dual-era sniff treats an `initialize` carrying the modern envelope as modern traffic (a modern client naming a removed method)
instead of the legacy-distinctive handshake, and `server/discover` keeps its envelope exemption outside the modern era only.
The envelope-less rejection on a modern-locked session adopts #491's wire shape, `-32602` with the detailed message.
One deliberate divergence from the Python SDK: its dual-era stream loop answers an `initialize` on
a modern-locked connection with `-32022` carrying the supported-version list, while its HTTP path serves
the 404/-32601 shape. The conformance requirement phrases removed methods transport-independently,
so this SDK answers `-32601` on every transport, stdio included, for uniform era semantics.
The stdio era-lock tests used a modern `ping` as the era-distinctive request; they now use `tools/list`,
since `ping` no longer exists in the modern lifecycle.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures and RuboCop reports no offenses.
New and updated tests cover each admission rule on `Server#handle`, the Streamable HTTP modern path, and the stdio era lock
(all five removed methods per transport, the envelope requirement on `server/discover`, and the envelope-carrying `initialize` routing).
Against the conformance fixture server, every admission check of the `server-stateless` scenario passes at
`--spec-version 2026-07-28`: the per-request `_meta` validation, removed-method 404, discovery, capability,
and version-negotiation checks are all green, and the only remaining failures are the `subscriptions/listen`
checks that belong to separate work. The `--requirements 2025-11-25` server leg passes 78/78, unchanged.
## Breaking Changes
None for stable protocol versions: legacy requests are byte-for-byte unchanged, and a bare legacy `_meta`
(`progressToken`, trace context) keeps its legacy classification. Within the modern lifecycle,
previously incidental error shapes are replaced by the ones the 2026-07-28 conformance requirements mandate.
## Motivation and Context
Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation,
both introduced against the frozen SEP text and overtaken by post-final spec changes:
1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it
optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only
the `protocolVersion` + `clientCapabilities` pair.
A conformant client configured not to identify itself was rejected.
2. A missing or mistyped envelope answered `-32600` Invalid Request.
The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer
with `-32602` naming the offending keys.
Fixing these also corrects the era classification to match the reference SDKs: a request claims
the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion`
(the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks),
and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming
the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without
the claim key (`progressToken`, trace context) is classified exactly as before.
Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on
a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK.
## How Has This Been Tested?
`test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone,
a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo`
(reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities`
and for mistyped required and optional fields.
`test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed
but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is
served with `server_context.client_info` reading `nil`, and a claim-less request on
a modern-locked session answers `-32602` naming the required keys.
The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths.
## Breaking Changes
None for conforming clients. Requests that were already rejected change error code
(`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key
with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests.
Envelopes without `clientInfo`, previously rejected, now succeed.
## Motivation and Context
#490 and #492 merged independently green but broke each other on main: #492 made the modern path reject
a name-bearing POST whose body carries a target name without `Mcp-Name` (-32020, HTTP 400)
and taught the `modern_rack_request` helper to derive `Mcp-Method` from the body, while #490,
merged in between, added five modern `tools/call` tests that build their requests through that helper
and therefore send no `Mcp-Name`. Since the #492 merge every CI run on main fails those five tests
with 400 where 200 is expected; the library behavior itself is correct.
The helper now derives `Mcp-Name` the same way it derives `Mcp-Method`: from `params.name` or
`params.uri` when the body's method is one of the name-bearing three. That is what a conforming client sends per
the 2026-07-28 specification, and what the TypeScript SDK's client, the bundled `MCP::Client::HTTP`,
and the conformance harness's standard headers all do, so tests built through
the helper model a compliant client by default. `name_header: nil` omits the header for tests of
the requirement itself, symmetric with `method_header: nil`, and an explicit `headers:` entry still overrides
the derived value, which keeps the base64 mismatch tests exercising their divergent names.
## How Has This Been Tested?
The five failing tests pass again without being touched, and the #492 requirement tests
(missing `Mcp-Method`, missing `Mcp-Name`, header mismatches, base64 decoding) still pass:
the transport test file reports 217 runs with zero failures. `bundle exec rake` is green,
including RuboCop and the `--requirements 2025-11-25` conformance baseline.
## Breaking Changes
None. The change is confined to a test helper; the shipped gem is untouched.
## Motivation and Context
The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange,
and two delivery requirements of that model were unimplemented:
- Notifications a handler emits while serving a modern request (progress, log messages)
had nowhere to go: the modern path answered with a single JSON object,
and the ephemeral per-request session degraded every notification to non-delivery.
The conformance requirement expects them as frames on the request's own response stream
(`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28).
- The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC,
was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement
that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`.
The changes wire both up:
- `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session;
`send_notification` routes into the sink ahead of the stateless guard.
A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response;
a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
- `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`,
and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level:
without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent,
the safe direction, matching the Python SDK's `allowed_log_levels`.
The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline
rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as
not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation`
and requires that every frame on the request's response stream is a notification or the final response,
and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel`
and requires that no `notifications/message` reaches the stream.
The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes;
`test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate,
which drops the notification for modern requests that did not authorize logging through
`io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds:
with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame
ahead of the final response rather than being dropped.
One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs:
delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved
and the conformance checks are satisfied, but long-running handlers get no real-time interleaving.
The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications
in proportion to client-supplied input cannot grow one request's memory without bound,
and past the cap the notify helpers report `false`, the same non-delivery degradation
they have on every other undeliverable path. Live streaming can follow without changing the wire shape.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover:
a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying
in place without notifications, log suppression without the envelope `logLevel`, log delivery with it,
the modern-era session ignoring the server-wide level while honoring a per-request one,
and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it.
Against the conformance fixture server, the `tools-call-with-progress` progress check passes
at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change),
the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and
`sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive
directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`,
`test_logging_tool` streams its `notifications/message` frame ahead of the result, and
with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame.
The `--requirements 2025-11-25` server leg passes 78/78, unchanged.
## Breaking Changes
None for stable protocol versions: legacy requests and their notification delivery are unchanged.
Within the modern lifecycle, notifications become deliverable where they were previously dropped,
and log messages without a per-request `logLevel` stop leaking through the server-wide level,
as the specification requires.
## Motivation and Context
The modern Streamable HTTP path validated the SEP-2243 mirror headers only when present:
an absent `Mcp-Method` or `Mcp-Name` was silently tolerated. The 2026-07-28 specification
requires `Mcp-Method` on every request and `Mcp-Name` on `tools/call`, `resources/read`,
and `prompts/get`, and lists a missing required standard header among the validation failures
a server MUST reject; the TypeScript SDK enforces presence with `-32020` the same way.
Tolerating absence also defeats the headers' purpose silently: they exist so intermediaries
can route and inspect requests without parsing bodies.
A modern POST without `Mcp-Method`, or a name-bearing modern POST whose body carries
a target name without `Mcp-Name`, now answers HTTP 400 with `-32020` naming the missing header,
exactly like a mismatch. `Mcp-Name` stays unrequired when the body carries no target name,
matching the TypeScript SDK. The bundled `MCP::Client::HTTP` already sends both headers on
every modern request, so SDK-to-SDK traffic is unaffected.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover
the missing `Mcp-Method` and missing `Mcp-Name` rejections, asserting the `-32020` code
and the header name in the message. The `modern_rack_request` helper now mirrors
a conforming client by deriving `Mcp-Method` from the body (with `method_header: nil` to omit it),
so the existing modern-path tests exercise the requirement on every request.
## Breaking Changes
None for conforming clients, which must already send these headers.
Hand-rolled modern clients that omitted them are now rejected with `-32020` instead of being served;
add the headers, or use the legacy lifecycle, to proceed.
## Motivation and Context
Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic.
Server side, `Server#discover` reaches its spec-final shape:
- `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
legacy versions are negotiated via `initialize`, not selected from discovery. This also closes
an interop hazard of the interim response: auto-negotiating TS and Python clients treat
any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`,
so advertising only legacy versions made them fail outright.
- The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints
on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with
`ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise.
- The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp
instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002
(the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level
`serverInfo`, so the top-level placement was invisible to both). `Client#discover` and
the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built
against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`.
Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default:
- `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct
(`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`).
It works before `connect`; the stdio transport's connect guard exempts `server/discover`
and spawns the subprocess on demand.
- `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs
(the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:`
(the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover`
and falls back to the legacy handshake on any discovery failure, including a successful discovery
without a mutually supported modern version (rollout tolerance for servers that answer discovery
while only serving legacy versions). Custom transports whose `connect` does not declare `mode:`
keep receiving the historical legacy call shape, detected by keyword introspection;
a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords
stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError`
instead of silently downgrading.
- The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`,
instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried
the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make
the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version,
the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here
as `mode: :modern` with `protocol_version: "2026-07-28"`.
- An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe,
so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle.
`mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools
and when using server-initiated requests, which exist only on the legacy lifecycle.
- Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends
on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference:
`MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one),
`#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta`
`io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server
does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover.
- The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK)
when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately,
but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback
instead of blocking `connect` forever. Regular requests keep the unbounded default.
- In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto
every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names
from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and
keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize`
is never sent.
- The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake
and its scenario servers do not expect a preceding probe.
Resolves#389.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values);
the existing discover assertions across `server_test.rb` and both transport test files were updated to
the modern-only `supportedVersions`.
New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError`
on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`,
the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`,
`ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection,
and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence.
New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with
the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks,
and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`,
modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout
default applying only while probing, and the fallback when a server never answers the probe.
## Breaking Changes
`server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`);
this method has not shipped in a gem release yet, so no released behavior changes.
On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before
the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with
an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing
the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation
`protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable
across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
## Motivation and Context
Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first,
like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside
every supported list enters a new sessionless modern path, while requests without the header (or with
a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path
means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }`
instead of a generic invalid-request error.
Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model,
and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates
by request shape: an `Mcp-Session-Id` binds the request to an established legacy session
(POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST
whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over
the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic
(`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request,
since Rack 3 inputs need not be rewindable.
The modern path (`handle_modern`) is a single POST/JSON exchange:
- GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405.
- It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`,
and rejects requests carrying one with HTTP 400.
- Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path.
- Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against
the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent.
`Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII.
- HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400,
`-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else
including internal errors stays 200.
- Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`,
whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of
broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`.
The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple,
which would previously fall through the legacy path via the header default. Session-bound under
a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because
the session already negotiated the legacy lifecycle (mirroring the stdio era lock).
With the header missing or naming a stable-only version, it violates the header/body match
requirement and is rejected as a header mismatch (`-32020`).
Intentional behavior changes for previously-erroneous requests, all following from header-primary routing:
an unknown header version now yields `-32022` with data instead of the legacy `-32600` message
(`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version,
or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed
on the legacy path. Existing tests were updated to codify these.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list
for unknown header versions, `-32020` for header/body version, `Mcp-Method`,
and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection,
405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`),
`-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without
an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.
Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy
and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path,
a session-bound POST carrying the modern envelope is rejected as `-32600`,
and an oversized modern POST returns 413.
## Breaking Changes
None for conforming clients: requests without the header or with a stable-only version
are byte-identical to before, and legacy clients negotiating 2026-07-28 over
the classic handshake connect unchanged. Requests that were already rejected change error shape
(see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
## Motivation and Context
Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.
- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.
`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.
## Breaking Changes
None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
## Motivation and Context
Clients that require MCP 2026-07-28, such as ChatGPT, reject connections because the SDK
does not advertise that version: `Server#init` answers a 2026-07-28 request with 2025-11-25,
the Streamable HTTP transport 400s an `MCP-Protocol-Version: 2026-07-28` header,
and `server/discover` omits the version from `supportedVersions`.
Making 2026-07-28 the latest entry in `SUPPORTED_STABLE_PROTOCOL_VERSIONS` fixes all three at once,
since they all read that list. The version now serves both lifecycles of the SEP-2575 dual-era model:
negotiable through the legacy `initialize` handshake, and simultaneously the version of
the per-request modern lifecycle.
Parts of the 2026-07-28 feature set remain unimplemented, such as the stateless modern lifecycle
over the transports, and are being built separately. This change deliberately limits itself to
the version bump, focusing first on eliminating the connection errors that 2026-07-28 clients hit.
Known follow-up: the in-progress modern HTTP path routes requests to the sessionless modern lifecycle
when the header version is absent from `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, so that routing predicate
needs rework on top of this change.
Closes#474
## How Has This Been Tested?
New regression tests: `initialize` with `protocolVersion: "2026-07-28"` echoes it back (`server_test.rb`),
a follow-up POST with `MCP-Protocol-Version: 2026-07-28` passes header validation
(`streamable_http_transport_test.rb`), and `Configuration.new(protocol_version: "2026-07-28")` is accepted
(`configuration_test.rb`).
`bundle exec rake test` passes (1454 runs, 0 failures). The conformance server suite passes 30/30.
The client `initialize` scenario is added to the expected-failures baseline: the conformance framework pins
the client's requested version to 2025-11-25 and does not accept 2026-07-28 yet; the entry carries a TODO
to remove it when the framework catches up.
## Breaking Changes
Servers that do not pin `configuration.protocol_version` now answer version negotiation fallbacks
and default client requests with 2026-07-28 instead of 2025-11-25. 2025-11-25 remains fully supported
and negotiable; clients that request it are unaffected.
## Motivation and Context
The Rack 3 SPEC requires response header names to be lowercase.
https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section:
> Header keys must not contain uppercase ASCII characters (A-Z).
https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers
However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`.
Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response
from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance.
HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected;
only in-process consumers that read the raw Rack headers hash see the new keys.
Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names
a protocol error on HTTP/2, so proxies and h2-capable servers already require it.
The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`,
so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention,
not a wire requirement.
Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name
a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers,
which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as
an `Access-Control-Expose-Headers` value, which browsers match case-insensitively;
and README/docs prose follows the MCP specification's documentation convention.
Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys.
AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing
invites reintroducing capitalized names in future transport work.
## How Has This Been Tested?
Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses.
Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop,
and rake conformance.
## Breaking Changes
Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport,
with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are
unaffected because header names are case-insensitive on the wire.
## Motivation and Context
This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems.
Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer
disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`,
and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense
from the new `Style/ReduceToHash` cop.
This change fixes them as follows:
- Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7,
where Regexp literals are not frozen.
- Remove inline disable directives that became redundant.
- Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names
can reference class names such as `NoMethodError`.
- Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test
since Mocha cannot stub methods on frozen objects.
- Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`.
Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0
## How Has This Been Tested?
`bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The conformance suite also passes against its baseline.
## Breaking Changes
None. Freezing these constants only affects code that mutates them in place, which has never been supported.
## Motivation and Context
`StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`,
which matches on the method name alone. An `initialize` message sent without an id (framed as a notification)
therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized,
but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil.
Two things then went wrong:
- `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response,
so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`)
and the client gets a malformed response or a 500.
- The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized.
The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions
toward the `max_sessions` cap until the reaper reclaims them.
`handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session
and acks with 202, mirroring how `handle_regular_request` already handles a nil response.
A well-formed `initialize` request (with an id) is unaffected.
## How Has This Been Tested?
A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize`
with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map.
It fails against the previous code (a 200 with a nil body element and a retained session) and passes now.
## Breaking Changes
None. A conforming client sends `initialize` as a request with an id and is unaffected;
only the malformed id-less form changes, from a broken 200 to a 202 with no retained session.
## Motivation and Context
In `StreamableHTTPTransport#handle_post`, the response and regular-request branches reject
a message whose session no longer exists (the response branch via `session_exists?`,
the regular path via `validate_and_touch_session`), but the notification branch dispatched unconditionally.
A notification carrying a present-but-unknown (or expired-but-not-yet-reaped) `Mcp-Session-Id`
therefore passed the `missing_session_id` gate and the ownership gate (which returns true
when the session does not exist) and reached `dispatch_notification` with no session,
running against the shared `Server` instead of a live `ServerSession`. With the default no-op notification
handlers this had no visible effect, but a custom notification handler would then run without
a live session behind it.
`handle_post` now checks `session_active?` in the notification branch, mirroring the other two branches.
`session_active?` rejects an unknown or expired session (evicting the expired one) without extending its lifetime,
so a stale session ID yields `404 Session not found` and a live session is dispatched exactly as before.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` assert that
a notification with an unknown session ID is rejected with `404 Session not found`
and that a notification carrying a live session ID is still accepted with `202`.
The new rejection test fails against the previous code (it returned `202`) and passes now.
## Breaking Changes
None for a conforming client: a client that has completed `initialize` sends its notifications with
the live session ID and is unaffected. Only a notification bearing an unknown or expired session ID,
previously accepted with `202`, now receives `404 Session not found`.
## Motivation and Context
SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.
The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):
- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
and never falls back to the GET stream.
Resolves#381.
## How Has This Been Tested?
- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
`create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
"attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
stays clean; their assertions are unchanged.
## Breaking Changes
None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
## Motivation and Context
`bundle exec rake` fails on main with four errors in `streamable_http_transport_test.rb`:
```
NoMethodError: undefined method '[]=' for nil
streamable_http_transport_test.rb:5119 (install_mutex_probe_stream)
```
This is a semantic conflict between two merged PRs. #451 made the server reject `initialize` requests
that lack the required `protocolVersion`, `capabilities`, and `clientInfo` params with -32602. #449,
merged after but developed before it, added the "writes outside the mutex" tests,
whose `initialize_test_session` helper sends an `initialize` request with no params.
That request is now rejected, no session is created, so `@sessions[session_id]` is nil and installing
the probe stream raises `NoMethodError`.
The fix adds `params: initialize_params` to the helper's request body, matching the pattern used
by every other `initialize` request in the file via `InitializeParamsTestHelper`.
## How Has This Been Tested?
`bundle exec rake` now passes: 1318 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The four previously failing tests are the regression coverage.
## Breaking Changes
None. Test-only change.
## Motivation and Context
Fixes https://github.com/modelcontextprotocol/ruby-sdk/issues/450
The MCP schema requires `protocolVersion`, `capabilities`, and `clientInfo` on `initialize` params,
and `uri` on `resources/subscribe` and `resources/unsubscribe` params. The server accepted requests with
these fields absent: `initialize` silently negotiated the server's default protocol version,
and `resources/subscribe` returned an empty success result. Both now return `-32602` Invalid params,
matching the Python SDK, which rejects such requests during pydantic validation.
The `uri` check runs before dispatch so it also covers handlers registered via `resources_subscribe_handler`
and `resources_unsubscribe_handler`.
A `protocolVersion` value that is present but unsupported still negotiates the server's fallback version,
as the spec's lifecycle section requires.
## How Has This Been Tested?
Reproduced the issue's stdio repro steps: both malformed requests now return `-32602` instead of succeeding.
## Breaking Changes
Clients that send `initialize` without `protocolVersion`, `capabilities`, or `clientInfo`,
or `resources/subscribe` / `resources/unsubscribe` without `uri`, now receive `-32602` Invalid params
instead of a success response. Such requests violate the MCP schema, but any code relying on
the previous lenient behavior (for example, test harnesses sending bare `initialize` requests)
must now include the required fields.
## Motivation and Context
`StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`.
The send paths wrote to an SSE stream while holding that lock: the targeted and
broadcast branches of `send_notification`, the server-to-client `send_request`,
and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream`
(a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`.
A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly,
the server-side `write` waits until the socket buffer drains.
Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking):
new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions
all wait behind one slow reader. The broadcast path holds the lock across every session,
so one slow stream also delays delivery to the sessions after it.
The cleanup paths already release the lock before touching a stream: they collect `streams_to_close`
and call `close` outside `synchronize`. This change applies the same discipline to the writes.
- Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers
the pending response there), then releases the lock before writing.
- A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`,
which mutates `@sessions` under `@mutex` and closes the affected streams outside it.
A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session,
matching the previous behavior.
- `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`;
the return values (true/false for a session, the delivered count for a broadcast) are unchanged.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records,
via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification,
broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now.
The existing send and cleanup tests still pass.
## Breaking Changes
None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
## Motivation and Context
A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised
on a non-object value parsed from an incoming frame:
- `JsonRpcHandler.handle` maps each element of a batch array through `process_request`,
which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]`
or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`,
so the error escaped; over stdio it escaped the `StdioTransport#open` read loop.
The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio.
- `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`,
which raises `NoMethodError` when the server emits a non-object frame.
Only `JSON::ParserError` was rescued, so one malformed line from the server escaped.
Both are robustness gaps: a malformed frame should be reported or skipped, not raise
an unhandled exception out of the transport.
- `process_request` now returns an `INVALID_REQUEST` error response for a non-object request
before touching `request[:id]`, so a malformed batch element becomes a normal per-element error.
- `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response,
so an unexpected handling error still produces a JSON-RPC reply.
- `read_response` skips a non-object frame the same way it skips a frame without an id.
## How Has This Been Tested?
New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements
(`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element,
and `handle_json("[[]]")` returning an error instead of raising. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and
asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb`
has the server emit non-object frames before the response and asserts the client skips them
and still returns the response.
## Breaking Changes
None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
## Motivation and Context
Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:
- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
in the reporter's measurement. The path runs before session validation,
so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
so a peer that never emits a newline grows a single String until the process is OOM-killed.
This matters when a host pipes an untrusted server's stdout into an unconstrained process.
The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.
- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
rejects a declared `Content-Length` over the cap before reading, and also bounds
the actual read to one byte past the cap so a missing or spoofed `Content-Length`
(chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
`max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
The constructor validates the new option as a positive Integer, since `nil` or a non-positive
value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
a separate advisory, so this change carries the remaining piece of the reported stdio fix:
an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
fails cleanly instead of parsing a truncated frame.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).
## Breaking Changes
None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
## Motivation and Context
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates
an incoming request by session existence and idle timeout only, with no binding to a client identity.
An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST
a `tools/call` on that session and the server executes it, silently poisoning the victim's session.
The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path,
so this is not covered by it.
Binding a session to a user is fundamentally the deploying application's responsibility:
the transport hands the server only the JSON-RPC body string and never receives the authenticated principal
(`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as
a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present.
This gives the Ruby SDK the seam to do the same:
- A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }`
on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects
with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses,
not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel
a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover.
`initialize` is exempt because it establishes the session. An application that has an authenticated principal
uses the validator to enforce that the caller owns the session; without one, ownership is not enforced.
- A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header
is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when
both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC)
is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker.
Enforcing ownership against a determined attacker requires the validator.
Stateless mode retains no sessions, so neither check applies to it.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs
from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed;
a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID;
the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests;
and stateless mode never consults the validator.
## Breaking Changes
None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both
the session and the request carry an `Origin` and they differ, so existing applications are unaffected.
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
## Motivation and Context
CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.
This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.
Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.
Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.
## How Has This Been Tested?
Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.
Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.
Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.
The full test suite and RuboCop pass.
## Breaking Changes
None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
## Motivation and Context
In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored
a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker
could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until
memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client.
The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count,
so the insecure behavior was the default.
Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python,
and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults
in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below):
- `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default
and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode
retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from
an explicit `nil`.
- A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under
the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions
the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired),
and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects:
established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions
while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS,
which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode.
The value is validated as a positive Integer or `nil`.
## How Has This Been Tested?
New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and
the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep
no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session
is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out,
and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to
the new secure default.
## Breaking Changes
Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions
lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require
the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
## Motivation and Context
SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release)
makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of
per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both
the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and
the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration
and are purely additive:
- The `server/discover` method, which servers MUST implement: sessionless capability discovery
returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`.
The new `Server#discover` handler reuses the same data `init` already builds, but is state-free
and idempotent: it stores no client info, never marks a session initialized, and responds regardless
of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`).
`serverInfo` is returned unfiltered because discovery precedes version negotiation;
the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work.
On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement
and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with
a bare POST in both stateful and stateless modes.
- The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003`
and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet
(that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of
`URLElicitationRequiredError` is intentionally left untouched.
The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is
deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25,
where those methods are required.
Part of #389.
## How Has This Been Tested?
- `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities,
server info, and instructions; it responds before `initialize` and with empty capabilities;
it does not mark the session initialized (a subsequent `initialize` still succeeds);
`instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")`
now raises `MethodAlreadyDefinedError`.
- `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID
returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`.
- New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None at the wire level for existing clients. One narrow API consequence: servers that previously registered
their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because
the method is built in.
## Motivation and Context
A JSON-RPC notification carries no id and must not receive any response.
When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id,
the server replied with a successful `result: null`, which is neither a valid MCP result nor
a legal reply to a notification.
Such a message is now treated as a request for a method that has no request handler and rejected with
Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification
that omits the id continues to receive no response.
Fixes#434.
## How Has This Been Tested?
Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`,
and `notifications/progress`, a batch that mixes an id-bearing notification with other messages,
custom `notifications/*` methods, and the same case over the HTTP transport.
## Breaking Changes
None for spec-conformant clients, which never put an id on a notification.
The only behavioral change is that a malformed id-bearing notification now receives a Method not found error
instead of a spurious `result: null`.
## Motivation and Context
MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate
the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost,
to prevent DNS rebinding attacks against locally bound MCP servers.
`StreamableHTTPTransport` previously validated neither `Host` nor `Origin`,
so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin,
invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m.
The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of
external middleware) and the Python SDK (`transport_security.py` disables protection by default)
both ship this off for backwards compatibility, a constraint they cannot escape after 1.0.
Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline:
- `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`,
compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`.
This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`.
A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one.
- `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port)
case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches
`Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected.
- `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers.
A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:
- a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed
- a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values
(`127.0.0.1`, `localhost`, `[::1]`) are allowed
- an upper-cased loopback `Host` is accepted (case-insensitive matching)
- a non-loopback `Host` listed in `allowed_hosts:` is allowed
- a request with no `Host` header, and one with no `Origin` header, are allowed
- a default-port `Origin` (`http://localhost`) matches `Host: localhost:80`
- opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected
- a `host:port` `allowed_hosts:` entry matches that port only
- `Host` validation also applies to GET requests
- `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through
## Breaking Changes
`StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default.
A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`,
or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients
are unaffected.
Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
## Motivation and Context
SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release)
makes MCP sessionless: every Streamable HTTP POST must be fully self-contained,
with no protocol-level session state shared between requests.
`StreamableHTTPTransport` already implements the SEP's transport surface via
`stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405,
DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak:
stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and
`@client_capabilities` onto the shared `Server` instance.
Concurrent stateless requests could therefore observe another client's identity,
and the data persisted across requests, which is exactly what SEP-2567 forbids.
The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack)
solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture:
- Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now
run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`),
so client info, logging level, and initialized state live only for the duration of that POST.
Repeated `initialize` requests are naturally permitted because each POST gets a fresh,
never-initialized session.
- `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising.
With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message`
would otherwise route every call into the exception reporter; non-delivery matches how
these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises,
as those are genuinely unsupported without a stream.
Resolves#388.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:
- a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched
(the leak regression)
- repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header
- a tool calling `server_context.report_progress` under stateless mode returns its result normally and
the exception reporter is never invoked
- the existing "stateless mode does not support server-sent events" test is updated to assert
the new `false` return instead of the removed raise
All other existing stateless-mode tests pass unchanged.
## Breaking Changes
`StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`.
The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation,
and the boolean return matches the method's documented semantics in every other non-delivery case.
Default (session-oriented) mode is unchanged.