## Motivation and Context
An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers`
in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported`
or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity.
No origin rule can settle this, because an authorization server is legitimately a different origin
from the resource it protects. Validating that a token was issued for the intended audience is a responsibility
the specification places on MCP servers rather than on clients, and there is no portable way for a client
to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor
the `resource` parameter or to report an audience in the token response.
Two things follow from that, and they are separate.
The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows
which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing
the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse.
It is one object rather than keyword arguments so that later revisions of the specification can add to it
without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it
runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`:
a refusal must not leave this client registered at an authorization server the host just rejected.
The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers
the authorization server on every call, so the server named for a session can differ from the one
the stored tokens came from. SEP-2352 already binds client credentials to their issuer,
but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL,
which is portable across authorization servers. The issuer that minted the tokens is now recorded with them,
and `refresh!` refuses to present them to a different one. The transport answers that refusal by running
a full authorization, which brings the new authorization server to the validator above. Tokens stored before
this carry no issuer and keep refreshing.
The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way,
because the specification requires a client to treat the scopes in the challenge as authoritative for the operation,
and narrowing them silently would break that.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises
before any registration request is sent and before the redirect handler runs, and that the `client_credentials`
and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against
a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb`
drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees
the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of
this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens,
and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected
until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer
it authorizes as before.
## 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
`MCP::Client::HTTP` caps a single message from the server at 4 MiB, but the OAuth flow talks to
authorization endpoints over its own Faraday connection, and that one had no cap. Discovery,
dynamic client registration, and token responses were buffered in full and handed to `JSON.parse`,
so a server that answered an OAuth request with an endless body grew the client's memory for as
long as it kept sending.
`Flow` and `IDJAGTokenExchange` each build their own connection, so both needed the bound.
The new `BoundedBody` counts bytes in a Faraday `on_data` callback and refuses the response once
it passes `MAX_RESPONSE_BYTES`, which matches the 4 MiB of `MCP::Client::HTTP::MAX_MESSAGE_BYTES`
and `MCP::Client::Stdio::MAX_LINE_BYTES`. It hands back the status paired with the bounded body
rather than the Faraday response, so no later caller can reach the unbounded `response.body`.
Those bytes are counted after decompression. The default Net::HTTP adapter negotiates `Accept-Encoding`
itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body that expands past
the cap is refused partway through the expansion. That holds only while the connection leaves
`Accept-Encoding` alone, which is now recorded in a comment on both `default_http_client` definitions.
An over-cap response is refused rather than truncated: a partial discovery or token document cannot be validated.
Protected resource metadata is the one place where refusing is not fatal, since any PRM discovery failure
already selects the legacy authorization path by design.
## How Has This Been Tested?
New unit tests in `test/mcp/client/oauth/bounded_body_test.rb` cover the streaming path,
the at-limit boundary, and the fallback for adapters that ignore `on_data`. One of them serves
a gzip body over a real socket rather than through WebMock, which returns a stubbed body verbatim
and never runs the inflater; it fails if the cap ever starts measuring compressed bytes. New tests in
`test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/id_jag_token_exchange_test.rb` drive each of
the four request wrappers end to end with an over-cap body; all five fail without the cap in place.
## Breaking Changes
An OAuth endpoint response larger than 4 MiB is now rejected instead of parsed.
An unknown prompt name and a missing required prompt argument are client input errors, but prompts/get raised them with the default Internal Error (-32603). The tools/call, resources/read, and completion/complete siblings already return Invalid Params (-32602) for the same not-found and missing-input conditions (resources/read via ResourceNotFoundError, per SEP-2164).
Add an explicit error_code so both map to -32602 while keeping the descriptive message and the existing instrumentation labels.
## 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
`MCP::Client#tools`, `#resources`, `#resource_templates`, and `#prompts` follow `nextCursor`
until the server stops offering one. The `seen` set in `fetch_all_pages` catches a server that
repeats or cycles cursors, but a fresh cursor on every response walks past that guard, and
the client keeps requesting and retaining pages for as long as the server keeps producing them.
The transports' per-message limits do not help here, since each individual page can be small.
`MCP::Client.new` now takes `max_pages:`, defaulting to `MAX_PAGES` (1000). Reaching it raises
`PaginationLimitError` rather than truncating the collection silently, since a short array would
be indistinguishable from a server that genuinely ran out of items. The single-page `list_*` methods
are unchanged, so callers who need their own policy still have one.
## How Has This Been Tested?
New tests in `test/mcp/client_test.rb` cover a server that returns a distinct cursor per page,
the boundary where a server stops exactly at `max_pages`, and the argument validation.
The existing repeat-cursor and cycling-cursor tests are unaffected.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
An all-pages call against a server offering more than 1000 pages now raises
`MCP::Client::PaginationLimitError` instead of continuing. Pass a larger `max_pages:` to
`MCP::Client.new` if that is expected.
## Motivation and Context
SEP-2322 handlers return `InputRequiredResult` instead of issuing in-flight server-to-client requests,
but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown `resultType`
as a final result, so the server rejected `input_required` on legacy requests with an internal error.
That forced authors to keep two handler styles, one per era.
This adds the dual-era authoring shim both reference implementations converged on:
the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381)
and the Python SDK's version-negotiating resolver (python-sdk#2986).
When a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on
the legacy wire, the server fulfills it in place of the client's driver:
- Every `inputRequests` entry is sent verbatim as the equivalent real server-to-client request through
a new `ServerSession#fulfill_input_request` (per the spec, clients treat each embedded entry exactly like
the standalone request), stamped with the originating request id so the association requirement of
SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream. One exception to
the verbatim forwarding: a URL-mode elicitation leg gains a synthesized `elicitationId` when
the handler supplied none, since the 2025-11-25 wire requires the field and the 2026-07-28 in-band
shape dropped it (correlation rides `requestState` there) - matching the TypeScript shim.
- The answers are collected under the same keys and the handler re-runs with `inputResponses`
and the raw `requestState` merged into the original params - the same deterministic replay contract
the modern client driver follows, read through the existing `server_context.input_responses`/`request_state` accessors.
Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through `requestState`.
- `requestState` round-trips in-process as the value the handler wrote. `RequestStateSecurity` sealing is
wire hardening against a tampering client and does not apply to the in-process replay.
- The embedded-capability gate still runs each round against the session's declared client capabilities;
a violation fails before any client contact. The `-32021` code is 2026-only, so the legacy wire gets
an internal error naming the missing capabilities.
- Rounds are capped at 8 (`LEGACY_INPUT_REQUIRED_MAX_ROUNDS`), matching the TypeScript shim's `maxRounds` default;
exhaustion fails with an explanatory internal error, which also bounds the `requestState`-only load-shedding form
that cannot make progress in-process.
The shim is on by default, matching the TypeScript SDK; `Server.new(input_required_legacy_shim: false)` restores
the strict rejection. Requests without a session (no notification path back to a client) and all modern requests
are untouched: the modern wire keeps returning the `input_required` result for the client driver.
Resolves#382.
## How Has This Been Tested?
`bundle exec rake` passes: 1691 test runs / 4381 assertions with zero failures and RuboCop clean.
The conformance baseline check passes on both legs (the frozen 2025-11-25 wire is where the shim lives),
and the modern `input-required-result-*` scenarios stay green, confirming the modern path is untouched.
New `test/mcp/server_input_required_legacy_shim_test.rb` covering: completion through
a real elicitation with the SEP-2260 association asserted on the transport, replay exposing
the collected responses and the raw `requestState`, sealing bypass under `RequestStateSecurity`,
`prompts/get` and `resources/read` shimming, multi-round collection, the round cap, the capability gate failing
before client contact, the URL-mode leg gaining a synthesized `elicitationId` (and a handler-supplied
one riding unchanged), a client answering a fulfilment leg with an error failing the original request,
the opt-out, session-less rejection, and modern requests staying untouched.
The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged.
## Breaking Changes
Only for handlers that returned `InputRequiredResult` to legacy clients THROUGH A SESSION and relied
on the internal-error rejection: those now drive a real fulfilment round trip by default.
No such handler could have worked end to end before (the request always failed), so this is the intended upgrade;
`input_required_legacy_shim: false` restores the previous behavior. Everything else is unchanged.
## Motivation and Context
Final step of SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322) for the 2026-07-28 MCP spec release.
The client so far only recognized `input_required` results by raising `InputRequiredError`; this adds the resume loop,
mirroring the Python SDK's high-level `Client` driver and the TypeScript SDK's `runInputRequiredDriver`.
- The driver answers embedded requests with the handlers already registered through `on_elicitation`,
`on_sampling`, and the new `on_roots`. There is no second registration path: a server can ask for input two
ways, as a real request mid-call and as an entry in an `input_required` result, and one registration covers
both. This follows the TypeScript driver, which dispatches "to the client's already-registered handlers
(elicitation, sampling, roots - one generic engine, no per-feature API)".
- `on_elicitation` and `on_sampling` no longer refuse a transport without `on_server_request`. They record
the handler either way and wire it to the transport only when it can carry server-to-client requests. Refusing
would have locked stdio clients, and every 2026-07-28 connection, out of the only route the spec leaves:
the modern lifecycle forbids server-to-client requests, so an embedded request is how it asks.
- `MCP::Client.new` gains `input_required_max_rounds:` (default 10, the TypeScript and Python default).
- Once a handler is registered, `call_tool`, `get_prompt`, and `read_resource` resume automatically:
each `inputRequests` entry is fulfilled by the matching handler and the ORIGINAL request is re-issued with
`inputResponses` under the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg
(modern envelope stamping happens in the transports per send, so every leg carries the SEP-2575 triple).
A `requestState`-only result (load shedding) retries after an exponential backoff from 50ms to a 250ms cap,
matching the Python SDK; every leg counts against the round cap, whose exhaustion raises `InputRequiredError`
carrying the last result.
- A requested kind without a matching handler falls back to the manual path by raising `InputRequiredError`,
and clients with no handlers keep the exact pre-existing behavior. For manual driving,
`call_tool` / `get_prompt` / `read_resource` gain `input_responses:` and `request_state:` keyword arguments
that ride at the params top level. Non-MRTR methods such as `tools/list` keep raising unconditionally.
The conformance client rides the same change: it derives its lifecycle from the wire version the harness names per run
(`MCP_CONFORMANCE_PROTOCOL_VERSION`), connecting with `mode: :modern` for the 2026 draft and keeping the pinned legacy
handshake through 2025-11-25, retrying a modern connect once when the server rejects the first request with `-32022`
naming its supported versions. New scenario branches drive `sep-2322-client-request-state` through this driver
(an accepting `on_elicitation` handler plus the four `test_mrtr_*` tools), `request-metadata`, both SEP-2243 header
scenarios, and `json-schema-ref-no-deref`. The expected-failures baseline drops the six scenarios this stack resolves,
leaving only the not-scored extensions and the post-anchor `json-schema-2020-12-preservation`.
Verified end-to-end over stdio: a modern connection (`connect(mode: :modern)` with declared form-elicitation capability)
against a server whose tool returns `InputRequiredResult` under `RequestStateSecurity` sealing completes in two legs with
the handler supplying the answer, and a handler-less client receives `InputRequiredError` with the sealed opaque state.
Part of #382.
## How Has This Been Tested?
New tests in `test/mcp/client_test.rb` (Mocha sequenced transports) cover: the two-leg happy path asserting preserved original params,
key-matched `inputResponses`, byte-exact `requestState` echo, and a fresh id per leg; the exponential backoff sequence for
`requestState`-only legs (stubbed `sleep`); round-cap exhaustion raising `InputRequiredError` with the last state; the unhandled-kind
fallback; the manual `input_responses:`/`request_state:` keyword arguments; and the `prompts/get` driver path. The existing SEP-2322
recognition tests pin the no-handler behavior unchanged.
Three tests cover the shared-registration design specifically: the driver resolving an embedded request on a transport that
has no `on_server_request` at all, an `on_roots` handler answering an embedded `roots/list`, and `on_elicitation` and
`on_sampling` accepting a registration on such a transport rather than raising, which replaces the two tests that pinned
the old refusal.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes, plus the stdio end-to-end script described above.
With the separate SEP-2243 mirroring change applied alongside, every scored scenario of the frozen 2026-07-28 client leg passes
(`sep-2322-client-request-state` 5/5, `request-metadata` 5/5 with no version-retry warning, `http-custom-headers` 18/18,
`http-invalid-tool-headers` 11/11), and the `--requirements` client legs at both revisions report failures only
in not-scored scenarios. The full conformance task passes its baseline check on both legs.
## Breaking Changes
`on_elicitation` and `on_sampling` no longer raise `ArgumentError` on a transport without `on_server_request`; they
register the handler for the `input_required` route instead. Code that relied on the refusal to detect an unsuitable
transport needs another check. Everything else is additive: the new keyword arguments default to `nil`, and with no
handler registered every code path is identical to before.
## Motivation and Context
`main` is red on every Ruby version: "modern non-cacheable results carry no cache hints" errors with
`undefined method 'key?' for nil`. The test reaches for `response[:result]` on a request that no longer
produces a result.
Two changes met on `main` without conflicting textually. #499 added the test and picked `ping` as its example of
a result that is not cacheable. #511 then made a modern-enveloped request naming a method SEP-2575 removed answer
with `-32601` even before the connection era locks, and `ping` is one of those methods, so the response now carries
an error rather than a result. Each branch was green against the `main` it was written on; the pairing only failed
once both had landed.
`ping` was the wrong example regardless. It is absent from the 2026-07-28 schema, so no modern request can name it
and receive a result of any shape. `tools/call` is the accurate stand-in: the spec types `CallToolResult` as
a plain `Result`, while the ones it types as `CacheableResult` are the discover, list, and read families.
The test now calls the tool it already registers, and a comment records why `ping` cannot come back here -
the same note #511 left where it replaced `ping` with `prompts/list` in the neighboring `resultType` test.
Only the test changes. The behavior both #499 and #511 implement is correct; the example the test chose was not.
## How Has This Been Tested?
The test errors on `main` and passes with this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
The rest of the suite was searched for the same pattern, a modern-enveloped request naming a method the modern lifecycle removed:
the only other occurrence is the assertion #511 added deliberately, which checks that such a request does not lock the era,
and which reads no result.
## Breaking Changes
None. No library code changes.
## 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
The OAuth client checked the scheme of every discovery URL but never checked where it pointed.
`Discovery.secure_url?` returns true for any `https` URL regardless of host, and the `resource_metadata` URL from
a `WWW-Authenticate` challenge is placed first in the candidate list, so a server answering
`401 Bearer resource_metadata="https://169.254.169.254/..."` had the client issue that request during discovery,
before the user approves anything. The same gap applied one hop deeper: a server that serves its own
Protected Resource Metadata can name any host in `authorization_servers`, and the endpoints in the document fetched
from there receive the dynamic client registration and the token request that carries the client credentials.
Two rules now apply to those URLs. Both run before the request is sent, and neither is configurable.
The `resource_metadata` URL must be on the MCP server's own origin. Protected Resource Metadata describes that server,
so a real deployment publishes it there, and requiring it means a challenge cannot aim the first request of the flow at
an unrelated host. RFC 9728 does not itself require this, so the SDK is deliberately stricter than the specification:
the range check below cannot recognize an internal host that is named rather than addressed, and this is the one URL
a server supplies directly.
The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint`
from Authorization Server metadata must not be IP literals in a private, loopback, link-local, or unique-local range,
which is the precaution RFC 9728 Section 7.7 asks clients to take. That check is skipped when the configured MCP server URL
is itself on such an address. Pointing a client at a private network is a deliberate act and its authorization server
usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network
keep working. Only a server reachable on the public internet is barred from steering the client inward.
A range check is only as good as its reading of the address, so the second rule also refuses hosts written in
the numeric IPv4 grammar `inet_aton` accepts and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal,
octal, or decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and `0x7f000001` all reach
127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, so reading only the dotted-decimal spelling would refuse
`169.254.169.254` while admitting every alias of it. Those hosts are refused outright rather than decoded and
range-checked, because their value is resolver-dependent: a leading zero reads as octal on one platform and decimal on
another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. Choosing a reading here would leave
whichever one was not taken as a way through, and nothing is legitimately addressed in this notation.
Hostnames are not resolved. A lookup here would be a second network request driven by the same untrusted input,
and the address it returned need not be the one the HTTP client connects to a moment later, so the check would read as
a guarantee it cannot make. `MCP::Client::OAuth::Flow` also documents why its Faraday connection has no redirect-following
middleware: every check runs against the URL as written, so a connection that follows a `3xx` on its own would reach hosts
these rules just refused.
## How Has This Been Tested?
- New unit tests in `test/mcp/client/oauth/discovery_test.rb` cover `same_origin?` (case-insensitive scheme and host,
explicit default ports, differing port/host/scheme, subdomains, malformed and hostless input) and `private_network_host?`
(every blocked range, IPv4-mapped IPv6 spellings, bracketed IPv6, `localhost` matched exactly, and public addresses and names).
One test documents that a named internal host reads as public, which is the deliberate limit of the predicate.
- Further unit tests cover the numeric spellings: packed integers, hexadecimal, and short forms that reach a blocked range,
and the ambiguous leading-zero forms whose value depends on the resolver. Removing the rule leaves them addressable,
which is what those tests fail on.
- New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/http_oauth_test.rb` drive both rules through `run!`,
`refresh!`, and the transport. They assert with `assert_not_requested` that no request was issued rather than only that an error
was raised, since nothing downstream can un-send one. One of them names the authorization server `https://0xa9fea9fe`, which
without the rule above is fetched. A further test runs a complete flow with the MCP server and the authorization server on
two loopback ports, which is what the carve-out exists for.
- `rake conformance` passes with all 22 auth scenarios green. Its harness runs the MCP server and the authorization server on
separate loopback ports and publishes PRM on the server's own origin, so it exercises exactly the shapes these rules must not break.
## Breaking Changes
A client whose MCP server is reachable on the public internet no longer follows a `resource_metadata` URL on a different origin,
and no longer contacts an authorization server or OAuth endpoint at a private IP literal or at a host written in the numeric
IPv4 notation above. All were previously allowed. Local development, private-network deployments, and the conformance suite
are unaffected.
## 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
`resources/subscribe` and `resources/unsubscribe` answered with a hardcoded `{}`, discarding whatever
the registered handler returned. A server had no way to attach anything to the response - not even `_meta`,
which the specification allows on every result. The handler docstring justified the discard as "always
an empty result `{}` per the MCP specification", but the spec does not require an empty object here:
the subscribe result is an `EmptyResult`, and every result may carry `_meta`.
The one field that is interoperable is `_meta`. The TypeScript SDK validates the subscribe result against
`EmptyResultSchema.strict()`, which rejects any member other than `_meta`, so a top-level field like
a bespoke `subscriptionId` would be refused by a TypeScript client. The Python SDK passes the handler's `EmptyResult`
return through, `_meta` included. Passing `_meta` matches both while staying within what the spec defines.
The subscribe and unsubscribe handlers now pass a returned `_meta` hash through to the result and drop anything else.
`subscription_result` reads `_meta` under a symbol or string key and includes it only when it is itself
a hash; every other return shape - a non-hash, or a hash without a usable `_meta` - keeps the empty `{}` result it
produced before. Advisory data such as a subscription identifier goes under `_meta`, namespaced, rather than
at the top level. This affects only 2025-11-25 and earlier connections, since `resources/subscribe`
and `resources/unsubscribe` are removed from the modern lifecycle.
Fixes#508.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover a `_meta` hash passing through on both `resources/subscribe`
and `resources/unsubscribe`, a non-`_meta` field being dropped, a string `_meta` key working, a non-hash `_meta`
being ignored, and a non-hash return keeping the empty result. The existing subscribe and unsubscribe tests,
which return `{}` and assert an empty result, are unchanged.
## Breaking Changes
None. A handler that does not return a hash carrying a `_meta` hash - which is every handler written against
the previous "return value is ignored" contract - still produces the empty `{}` result.
## Motivation and Context
`resources/read` can be taken over with `resources_read_handler`, but `resources/list` had no equivalent:
it was bound to the internal `list_resources`, which paginates the constructor-provided `resources` array,
and `define_custom_method` refuses to rebind a method the server already handles.
An application whose visible resources depend on the request - authenticated versus anonymous sessions,
or an OAuth-scoped connector that must see a scope-filtered set - had to build a fresh `Server` per request
or override a private method.
Both reference SDKs let the list vary per request. The TypeScript SDK exposes `resources/list` through
`setRequestHandler` and gives resource templates a per-context `listCallback(ctx)`; the Python SDK takes
an `on_list_resources` handler. This adds the Ruby equivalent.
`resources_list_handler(&block)` registers a block that returns the resource collection to serve.
It feeds `list_resources` rather than replacing it, so the framework still paginates the returned array
and stamps the SEP-2549 cache hints; the block returns only the array, matching how `resources_read_handler` returns
only the contents and the framework wraps them. A block that declares a `server_context:` keyword receives
an `MCP::ServerContext`, reusing the same opt-in rule the read handler uses, so the list can be filtered by
the authenticated principal or granted scope. The block is invoked once per page and must return
a stable ordering across the pages of one query, since the cursor is a positional offset. When no handler is set,
the constructor-provided resources are served unchanged.
The `resources` capability is advertised whether or not the constructor array is populated, so a server that
provides only a `resources_list_handler` still answers `resources/list`.
Fixes#507.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover a handler replacing the served collection, a handler receiving `server_context:`
and returning different sets for authenticated and anonymous requests, pagination and cache hints still applying to
the handler-provided array, and a server with no constructor-provided resources served entirely by the handler.
The existing `resources/list` test, which exercises the no-handler default path, is unchanged.
## Breaking Changes
None. When no `resources_list_handler` is set, `resources/list` serves the constructor-provided resources exactly as before.
## Motivation and Context
The SSE `retry:` field is chosen by the server, and `MCP::Client::HTTP` passed it to `sleep` unchanged in
both reconnection paths. `await_response_after_disconnect` runs on the calling thread, so a server that primes
a stream with an event id, closes it gracefully, and asks for a large `retry:` interval keeps that thread waiting
for as long as it likes. `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, not how long
it waits before each attempt, and no I/O timeout covers a sleep.
The obvious fix, clamping `retry:` to a ceiling, is not available: the spec says a client
"MUST respect the `retry` field, waiting the given number of milliseconds before attempting to reconnect",
and reconnecting sooner than a server asked for is also the wrong direction to err in, since `retry:` is how
an overloaded server asks for room. Both reference SDKs pass the value through untouched.
What the spec does allow is waiting longer. The `retry` field it points at is the one WHATWG HTML defines,
whose reconnection algorithm reads "Wait a delay equal to the reconnection time of the event source. Optionally,
wait some more." It also leaves reconnecting at all a SHOULD, which this client already declines once
`MAX_RECONNECTION_ATTEMPTS` is reached. Both fixes below sit inside that room; neither shortens a wait.
`await_response_after_disconnect` now carries a deadline, `max_reconnection_wait:` seconds
(`MAX_RECONNECTION_WAIT`, 300, the same budget as `SSE_LISTENER_READ_TIMEOUT`). Before each attempt it compares
the delay the server asked for against the remaining budget, and stops reconnecting when honoring it in full
would run past the deadline. The server's interval is therefore either waited out exactly or not acted on at all,
and the calling thread is released as soon as the answer is known. On the reported case, a day-long `retry:`,
the thread now returns without sleeping at all.
Whatever is left of the budget also becomes the resumed stream's read timeout, so the bound covers a server
that accepts the GET and then sends nothing. That GET previously relied on whatever the Faraday adapter
defaults to, which is 60 seconds for Net::HTTP but need not exist at all for a caller-supplied adapter.
`listen_for_server_requests` already guarded its own GET this way with `SSE_LISTENER_READ_TIMEOUT`.
The other path has the opposite problem. `listen_for_server_requests` treats a graceful close as success and
resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and the listening stream reconnects
in a tight loop. A `MIN_RECONNECTION_DELAY_MS` floor of 100ms stops that, and is the "optionally, wait some more"
case exactly. That path gets no deadline: it runs on a thread this client owns and is meant to poll indefinitely
(the spec asks clients to "poll" a closed stream by reconnecting), so a long `retry:` there idles the SDK's own
listener rather than the embedding application.
`DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` are unchanged and still match the Python SDK.
## How Has This Been Tested?
New tests in `test/mcp/client/http_test.rb` cover a day-long `retry:` releasing the calling thread without
sleeping, a delay that fits the budget still being waited out in full and resuming normally, a caller-supplied
`max_reconnection_wait:` stopping the resume with a message naming the budget, and the argument validation.
Both reconnection paths are covered for the unusable values a server can send: `retry: 0` is raised to the floor
on the resumption path and on the listening stream, and negative or non-numeric values (`-5000`, `abc`, `1e6`, `500ms`)
fall back to the default delay, since the SSE parser accepts only a run of digits. The existing reconnection tests,
whose `retry:` values fit the default budget, are unaffected.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance `sse-retry` scenario scores
`client-sse-retry-timing`, which checks the reconnect happens neither early nor very late against a 500ms `retry:`;
since no wait is ever shortened, that check is unaffected by the size of the value it uses.
## Breaking Changes
A request whose resumption cannot complete within `max_reconnection_wait:` (300 seconds by default) now fails instead of
waiting however long the server asked for. Servers asking for intervals that fit the budget are unaffected.
Pass a larger `max_reconnection_wait:` if you expect longer.
## 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 spec revision makes the SEP-2549 `ttlMs` / `cacheScope` cache hints REQUIRED members of
the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`,
and `resources/read`). The SDK's existing emission is opt-in (`Server.new(ttl_ms:, cache_scope:)`),
so servers that never opted in violate the 2026-07-28 wire schema, and the conformance suite fails
those scenarios on wire-schema validation (`ListToolsResult: must have required property 'cacheScope' / 'ttlMs'`)
plus all eight checks of the `caching` scenario.
The dispatch path now fills the hints centrally for results of requests carrying the SEP-2575 `_meta` envelope,
next to the SEP-2322 `resultType` stamp: a cacheable result that does not already carry both fields gets
the configured `ttl_ms` / `cache_scope` values, with `ttlMs: 0` (do not cache) and `cacheScope: "private"`
filling anything unset. Values already in the result win, preserving per-result overrides.
Only complete results are stamped: an SEP-2322 `input_required` round trip of `resources/read` is not
a cacheable final result and stays hint-free.
The unset-scope fill is `"private"` because the spec names no default scope and `"private"` is the side
that cannot leak a user-dependent `resources/read` result through a shared cache; it is also
the unconfigured default of the TypeScript SDK (`cacheScope: 'private'`), the Python SDK (`CacheHint.scope`),
and this SDK's own `server/discover`. The opt-in emission of `apply_cache_metadata` used to fill a missing
scope as `"public"` when only `ttl_ms` was configured and now fills `"private"` for the same reason,
so both paths agree. Stable protocol versions otherwise keep the opt-in emission unchanged.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: modern `tools/list`
and `resources/read` results carry the default hints, configured values win over the defaults,
non-cacheable modern results (`ping`) stay untouched, and legacy cacheable results keep the opt-in emission.
The opt-in emission tests assert the `"private"` fill for a configured `ttl_ms` without a scope.
The previously failing conformance scenarios (`tools-list`, `resources-read-text`, and `caching`) pass at
`--spec-version 2026-07-28` against the conformance fixture server, and the 2025-11-25 leg is unchanged.
## Breaking Changes
The modern-path emission is additive: it applies only to results of requests carrying the SEP-2575
modern `_meta` envelope. One deliberate change reaches stable versions: a server configured with
`ttl_ms:` but no `cache_scope:` now emits `cacheScope: "private"` instead of `"public"` on its opt-in results;
declaring `cache_scope: "public"` explicitly restores the shared-cacheable scope.
## Motivation and Context
SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate `inputSchema` properties with
`x-mcp-header` so intermediaries can route on argument values without parsing bodies: the client MUST
mirror each annotated argument of a `tools/call` into an `Mcp-Param-{Name}` header, encode values that
cannot ride as plain ASCII field values with the `=?base64?...?=` sentinel, and omit the header for
`null` or absent arguments. `MCP::Client::HTTP` sent only the standard `Mcp-Method`/`Mcp-Name` mirror
headers, so every `http-custom-headers` check of the 2026-07-28 conformance requirements fails.
The new `MCP::Client::McpParamHeaders` module ports the TypeScript SDK's `mcpParamHeaders` codec:
- `scan` walks a tool's `inputSchema` for declarations and validates every constraint the spec places
on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties
(`number` is accepted alongside the spec's `string`/`integer`/`boolean` because the published
referee annotates `type: "number"` parameters, the same accommodation the TypeScript SDK makes),
and static reachability through a chain of `properties` keys only: an annotation under `items`,
the combinators, or `$defs` invalidates the whole tool definition.
- `build` resolves each declaration's path in the call's `arguments`, omits `null`/absent and
non-representable values (unsafe integers, non-finite floats, non-primitives), converts primitives
per the spec's rules, and encodes with the sentinel exactly where a plain ASCII field value
cannot carry the value (empty, edge whitespace, bytes outside visible ASCII, sentinel-shaped input).
`MCP::Client::HTTP` learns declarations from the `tools/list` responses passing through it (the same
source of truth the TypeScript SDK's response cache uses) and mirrors on each `tools/call` alongside
the existing `Mcp-Method`/`Mcp-Name` construction. Learning and mirroring exist on the modern
lifecycle only, matching both reference SDKs. An invalid tool definition mirrors nothing, per
the spec's guidance to send without custom headers when no reliable declarations are available;
a complete (uncursored, `nextCursor`-less) listing rebuilds the registry so declarations of
unlisted tools stop mirroring (the Python SDK's pruning), and the registry is capped
(`MAX_MCP_PARAM_TOOLS`, 1000) so a server rotating tool names cannot grow it without bound.
On the modern lifecycle, `MCP::Client#list_tools` and `#tools` also enforce the spec's MUST:
a tool definition whose `x-mcp-header` annotations violate the constraints is excluded from
the listing, with a warning naming the tool and the reason (the spec's SHOULD), so one malformed
definition does not block the valid tools - the same filtering the Python SDK's
`_absorb_tool_listing` and the TypeScript SDK's cached-listing finalize apply. A value that cannot
be represented as UTF-8 omits its header like the other non-representable values.
The README documents the mirroring and the exclusion under the lifecycle negotiation section.
The server-side validation half of the codec is a follow-up.
## How Has This Been Tested?
New `test/mcp/client/mcp_param_headers_test.rb` covers the scan constraint matrix (root and
non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive
conversions (including `42.0` serializing as `42` and unsafe integers refusing), the encoding matrix
(plain pass-through, empty, non-ASCII, edge whitespace, CR/LF, sentinel-shaped input), and header
construction with nested paths and omitted values (including a value that cannot be represented
as UTF-8). `test/mcp/client/http_test.rb` drives the transport end to end over a modern connection:
a `tools/list` teaching the declarations, the following `tools/call` carrying the mirrored headers,
an invalid declaration mirroring nothing, a legacy connection learning nothing, and a complete
listing pruning the declarations of unlisted tools. `test/mcp/client_test.rb` covers the MUST-level
exclusion: the invalid definition dropped from `#tools` with the warning on a modern connection,
and listed unchanged on a legacy one. `bundle exec rake` is green.
## Breaking Changes
None. Requests to servers whose tools carry no `x-mcp-header` annotations are byte-for-byte unchanged,
and the new headers only appear for tools that declare them.
## Motivation and Context
The SEP-2322 `requestState` continuation string leaves the server, sits in the client's hands,
and comes back as client-controlled input. Without protection a client can read the server's continuation state,
tamper with it (for example forging an already-answered elicitation), or replay a state issued for one call against
another tool, other arguments, or another server. The multi-round-trip documentation of the Python SDK is explicit
that the echo must be treated as untrusted input; its high-level server seals the state by default via `RequestStateBoundary`.
New `MCP::Server::RequestStateSecurity` (OpenSSL standard library only) brings that protection to this SDK as an opt-in:
- `seal` encrypts the plaintext state with AES-256-GCM (clients cannot read it, not merely verify it) inside a claims
envelope binding an expiry window (`ttl:`, default 300 seconds, re-sealed each round), the originating method and target
(tool/prompt name or resource URI), a digest of the originating arguments (stringified and sorted recursively,
so symbol/string parses of identical JSON digest identically), and an optional `audience:`. The token format is
`v1.<base64url(iv || ciphertext || tag)>` with the version prefix bound as GCM associated data,
following the Python SDK's `AESGCMRequestStateCodec`.
- `unseal` verifies every claim fail-closed and raises `InvalidStateError` on tampering, expiry, or any mismatch.
Passing an instance via `Server.new(request_state_security:)` makes the seal/unseal transparent: issuance seals
the `requestState` of an outgoing `input_required` result, and dispatch unseals the echoed token for `tools/call`,
`prompts/get`, and `resources/read` before any handler runs, so `server_context.request_state` always reads
the plaintext the handler wrote. A tampered, expired, or cross-call echo is rejected as `-32602`
with "Invalid or expired requestState", matching the Python SDK's frozen error.
Without the option the state crosses the wire exactly as the handler wrote it (the Python low-level Server behavior);
the README documents that this is then the handler author's responsibility, and that multi-process deployments
must share the key across workers.
The conformance fixture opts in with a random per-boot key, both to dogfood the feature and because
the tampered-state scenario of the 2026-07-28 conformance requirements retries an MRTR request with
a corrupted `requestState` and requires a JSON-RPC error; without integrity-checked state the fixture
cannot detect the corruption. Every MRTR round trip completes within one server process,
so no key persistence is needed. Sealing is transparent to the fixture tools, whose `requestState` plaintext
round-trips unchanged, and the new `test_input_required_result_tampered_state` tool is therefore
a plain MRTR elicitation flow; the tampered echo is rejected with `-32602` by the server-level unsealing
before dispatch ever reaches it.
Refs #382.
## How Has This Been Tested?
New `test/mcp/server/request_state_security_test.rb` covers constructor validation,
the seal/unseal round trip (opaque `v1.` token, plaintext not visible), expiry via time travel,
fail-closed rejection of method/target/digest/audience mismatches, tampered and malformed tokens,
and tokens sealed under a different key.
New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope:
transparent sealing on issuance and plaintext restoration on the retry leg, `-32602` for
tampered echoes and for echoes replayed against different arguments, and the pass-through
default when `request_state_security:` is not set.
Against the fixture server at `--spec-version 2026-07-28` (run with `@modelcontextprotocol/conformance@alpha`;
the 2026-07-28 scenarios are not yet in a stable conformance release), `sep-2322-reject-tampered-state` reports SUCCESS,
and the other 13 `input-required-result-*` scenarios stay green with sealing active,
confirming that sealed states round-trip through the suite's retries without false rejections
(the seal binds `method`, target, and an arguments digest, and the suite echoes all three unchanged).
The `--requirements 2025-11-25` server leg still passes.
## Breaking Changes
None. The feature is opt-in via a new keyword argument that defaults to `nil`, in which case behavior is byte-identical to before.
## 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
SEP-2322 (merged for the 2026-07-28 spec release) makes `resultType` a REQUIRED member of every result
a 2026-07-28 server returns: "Servers implementing this protocol version MUST include this field",
with absence tolerated only when the result comes FROM an earlier protocol version.
The client-side vocabulary landed earlier (`MCP::ResultType`, treating an absent value as `"complete"`),
but every ordinary modern result the server returns is unstamped, which violates the MUST for `tools/list`,
`tools/call`, `ping`, and every other standard result on the modern wire.
The conformance suite's 2026-07-28 requirements fail each of those scenarios on wire-schema validation
(`CallToolResult: must have required property 'resultType'`).
The dispatch path now stamps `resultType: "complete"` centrally: any Hash result of a request carrying
the SEP-2575 `_meta` envelope that does not already carry a `resultType` gets the standard value merged in.
Results that already carry a discriminator keep it, notifications and cancellation-suppressed responses
are untouched, and legacy results stay unstamped because pre-2026 clients do not know the field.
`server/discover`, which is exempt from the `_meta` envelope (pre-version discovery),
stamps its `DiscoverResult` at its construction site instead.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: `tools/list`,
`ping`, and `tools/call` results carry `resultType: "complete"` on modern requests and no `resultType`
on legacy requests; `server/discover` carries the stamp. The conformance scenarios previously failing
on the missing property (`tools-call-simple-text`, `prompts-get-simple`, `completion-complete`, and
the other tools/prompts scenarios) pass at `--spec-version 2026-07-28` against the conformance fixture server.
## Breaking Changes
None. The stamp applies only to results of requests carrying the SEP-2575 modern `_meta` envelope;
results on stable protocol versions through 2025-11-25 are byte-for-byte unchanged.
## Motivation and Context
SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 MCP spec release)
replaces server-initiated JSON-RPC requests, which the stateless modern lifecycle (SEP-2575) forbids,
with multi round-trip results: a `tools/call`, `prompts/get`, or `resources/read` handler returns
`resultType: "input_required"` with `inputRequests` (server-assigned keys mapping to `elicitation/create`,
`sampling/createMessage`, or `roots/list` request shapes) and/or an opaque `requestState`,
and the client retries the ORIGINAL request with `inputResponses` under the same keys plus the echoed state.
The client-side recognition landed earlier (`MCP::ResultType`, `Client::InputRequiredError`);
this adds the server side, mirroring the Python SDK's low-level Server (python-sdk#2967/#2986)
and the TypeScript SDK's umbrella 2026-07-28 work.
- New `MCP::Server::InputRequiredResult` value object: construction validation
(at least one of the two fields, embedded methods restricted to the three shapes), `to_h` wire serialization,
and the capability mapping shared with the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`
(url-mode elicitation requires `elicitation.url`, form otherwise; sampling with `tools`/`toolChoice` requires
`sampling.tools`; `roots/list` requires `roots`), including the 2025 back-compat rule that a bare `elicitation: {}`
declaration implies form support.
- Handlers return the object through the existing paths: both branches of `call_tool_with_args` and
`call_prompt_template_with_args` pass it through instead of calling `.to_h`, `call_tool` skips
output schema validation and the structured-content fallback for it (output schema validation would otherwise
run against a `nil` `structuredContent`), and the `resources/read` dispatch skips the `contents` wrapping and
SEP-2549 cache-hint stamping.
- One central hook in the dispatch lambda gates and serializes the result, after the cancellation check
so cancelled requests stay suppressed: a legacy request (no SEP-2575 envelope) gets an internal error,
because pre-2026 clients treat an unknown `resultType` as a final result; embedded requests exceeding
the request's declared client capabilities get `-32021` with the full merged `requiredCapabilities` set.
- `MCP::ServerContext` gains `input_responses`, `request_state`, and the key-tolerant `input_response(key)` reader.
The retry fields are params-top-level siblings of `name`/`arguments`/`uri` (not `_meta`),
so only handlers that opt in to `server_context:` can participate, matching the envelope readers' access model.
The server holds no memory between rounds: handlers re-run from the start on every retry
(deterministic replay, as in the Python SDK).
The 2026-07-28 conformance requirements score 14 `input-required-result-*` scenarios and the capability
checks of `server-stateless`, and each drives a diagnostic fixture tool the server under test must define.
The fixture defines the tools those scenarios call, mirroring the suite's TypeScript reference fixture
(`examples/servers/typescript/everything-server.ts`) in tool names and `inputRequests` keys:
- `test_input_required_result_elicitation` / `_sampling` / `_list_roots`: one embedded request each,
answered on the retry round (also exercised by the missing-response, result-type, ignore-extra-params,
and validate-input scenarios)
- `test_input_required_result_request_state`: stamps an opaque `requestState` and completes only after
it round-trips
- `test_input_required_result_multiple_inputs`: all three embeddable methods in one result
- `test_input_required_result_multi_round`: two elicitation rounds with per-round `requestState`
- `test_input_required_result_capabilities`: builds `inputRequests` from the declared client capabilities,
so a sampling-only client is never asked to elicit
- prompt `test_input_required_result_prompt`: the non-tool MRTR flow over `prompts/get`
- `test_missing_capability`: guards on `require_client_capability!(:sampling)`, giving
the `server-stateless` capability checks a target that answers -32021 with HTTP 400
`test_input_required_result_tampered_state` is deliberately absent: rejecting a tampered `requestState` needs
the sealing machinery of the request-state security change, and its fixture tool lands there.
Sealing of the echoed `requestState` (it arrives as client-controlled input) and the client-side auto-fulfillment loop
follow in the next changes.
Refs #382.
## How Has This Been Tested?
New `test/mcp/server/input_required_result_test.rb` covers construction validation, key normalization and freezing,
the wire shape of `to_h`, the full capability mapping, missing-capability subtraction with symbol/string declarations,
and the implied-form back-compat rule.
New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: issuance wire shape for all three methods,
the retry leg exposing `input_responses`/`request_state`/ `input_response(key)` to the handler, the legacy-request internal error,
`-32021` with the merged `requiredCapabilities` data, output-schema validation bypass under `validate_tool_call_results: true`,
and `resources/read` results staying unwrapped without `ttlMs`/`cacheScope` stamping even when the server configures cache hints.
Against the fixture server at `--spec-version 2026-07-28`, all SEP-2322 checks of the 13 covered scenarios pass,
and `server-stateless` reports `sep-2575-server-rejects-undeclared-capability` and
`sep-2575-missing-capability-http-400` as SUCCESS. The `wire-schema-valid` failures on rounds that reach
a complete result come from the missing `resultType` stamp, which is the separate stamping change's job.
The `--requirements 2025-11-25` server leg still passes 78/78, and `bundle exec rake` is green.
## Breaking Changes
None. The serialization seams only branch on a return type that previously could not occur, all new keyword arguments default to `nil`,
and requests that carry no `inputResponses`/`requestState` behave exactly as before.
APM integrations need to tag a span with the subject of the request, but the
user-defined `server_context` hash passed to `Server.new` never reaches
`around_request` or `instrumentation_callback`. The exception reporter's
`server_context` argument is a different value — it describes where a failure
occurred (`{ request: ... }`, `{ notification: ... }`), not who made the call —
so there is currently no way to read the request subject from a hook.
Add `Configuration#instrument_server_context`, off by default. When enabled,
`instrument_call` puts the host's `server_context` into the instrumentation data
before invoking `around_request`, so it is readable both before and after
`request_handler.call`.
The flag is opt-in rather than unconditional because the hash is
application-supplied and may hold values a tracing backend should not receive.
Hosts that do not expose a `server_context` reader are unaffected.
`instrument_call` reads it through `self.server_context`: the existing
`server_context:` keyword shadows the reader, and the value wanted here is the
user-defined hash, not the reporter context.
Follows the same shape as the earlier additions of `tool_arguments` (#218) and
`client` (#221) to this hash.
## 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.
Tool handler exceptions and anything reaching JsonRpcHandler's blind
rescue had their #message echoed into the error data member, exposing
internals to untrusted clients (CWE-209). Capability errors keep their
client-facing message by re-raising through RequestHandlerError, the
one channel whose message is deliberately surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>