## Motivation and Context
The Deprecated Features paragraph still described the pre-#516 design, claiming the SDK
"emits deprecation warnings when they are used with protocol version 2026-07-28 or newer".
Since #516 the only deprecation warning is the client-side one, emitted on a modern connect
that declares the Roots or Sampling capabilities, exactly as the 1.2.0 CHANGELOG entry states.
Logging never warns (`notify_log_message` is the sanctioned SEP-2575 delivery path on that wire),
and the server never warns either: `initialize` counter-offers a legacy version,
a modern `Configuration` pin is rejected at construction, and the modern lifecycle refuses
the deprecated server-to-client request APIs outright rather than serving them with a warning.
The paragraph now describes the shipped behavior and says where each feature lives per lifecycle:
unchanged on legacy, carried on 2026-07-28 by input requests embedded in `InputRequiredResult` responses
(the SEP-2322 multi round-trip requests pattern) and the envelope's `logLevel`. Those carriers are not
the SEP-2577 replacements: the spec's deprecated-features registry points migrating implementations
outside the protocol (tool parameters or configuration, direct LLM provider APIs, stderr or OpenTelemetry),
and the paragraph now says so instead of presenting the modern delivery forms as the replacements.
## How Has This Been Tested?
Documentation-only change; proofread against the `ProtocolDeprecations` module header and
the CHANGELOG entry that shipped this behavior in 1.2.0, which the 1.3.0 release leaves unchanged.
## 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.
## Motivation and Context
The roadmap predates the 2026-07-28 implementation work and has drifted from the released SDK.
The 2026-08-15 SEP-1730 tier audit flagged the drift while evaluating the roadmap requirement:
- "Current Status" claims only 2025-06-18 and 2025-11-25 support, but the SDK now completes
the 2026-07-28 revision and passes all scored server and client conformance scenarios for each
revision's requirement set.
- The SEP-2575 stateless lifecycle rewrite was described as reserved for 2.0,
but it shipped during 1.x with both lifecycle eras served side by side;
the few incompatible adjustments shipped in a minor release under the spec-conformance
and security exceptions described in VERSIONING.md, as recorded in the 1.2.0 CHANGELOG entry.
- Deprecation warnings for Roots/Sampling/Logging (SEP-2577) were described as future work
but have shipped (#406, #516).
- The documentation section undersold the coverage: all implemented non-experimental features are
documented with examples (#472, #515).
The refreshed text records the implemented spec revisions and how the 2026-07-28 revision actually shipped,
and keeps the tasks extension (SEP-2663, #391) and the removal of legacy-era support as the remaining tracked items.
Gem versions are deliberately not named: the roadmap describes the current state of the SDK,
and the version-to-revision mapping already lives in CHANGELOG.md.
## How Has This Been Tested?
Documentation-only change; no code paths are affected. Statements were cross-checked against the v1.2.0 CHANGELOG entry
and the 2026-08-15 tier audit results (server 67/67 and client 50/50 scored conformance across the 2025-11-25
and 2026-07-28 requirement sets).
## Breaking Changes
None. This only updates ROADMAP.md.
This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification,
together with the SEP-2322, SEP-2549, and SEP-2243 features that revision builds on.
Several entries under "Changed" are incompatible with 1.1.0 and ship in a minor release
under the spec-conformance and security exceptions in VERSIONING.md.
Servers no longer echo 2026-07-28 from `initialize` and counter-offer 2025-11-25 instead,
`Configuration` accepts only handshake versions for its `protocol_version` pin,
server-to-client requests are bounded by a timeout and refused outright on the modern lifecycle,
the client bounds automatic pagination and its total SSE reconnection wait,
and `prompts/get` answers Invalid Params where it used to answer Internal Error.
Closes#512.
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
The SDK tiering system (SEP-1730) requires Tier 1 SDKs to document all non-experimental features with examples.
The 2026-08-14 documentation coverage audit found the last two example gaps: the Notifications section documents
`notify_prompts_list_changed` and `notify_resources_list_changed` in prose, but only `notify_tools_list_changed`
appeared in a code example, leaving the prompts and resources variants PARTIAL.
The Usage Example in the notifications chapter now defines a prompt and a resource and broadcasts
the matching list-changed notification for each, alongside the existing tools example. The block signatures mirror
the canonical `define_prompt` / `define_resource` examples earlier in the README.md.
## How Has This Been Tested?
Documentation-only change. The snippet was verified end to end against the current public API: both definitions
serve `prompts/get` / `resources/read` responses and all three notify calls run without error.
## Breaking Changes
None. This only extends a code example in README.md.
## Motivation and Context
The 2026-07-28 requirement set scores `http-standard-headers` (SEP-2243) on the client leg,
but `conformance/client.rb` had no case for it: the scenario hit the unknown-scenario abort,
the client exited 1, and every `Mcp-Method` / `Mcp-Name` check reported SKIPPED
("Client did not send a ... request"), failing the scenario in tier-check.
The 2026-08-14 tier audit flagged this as the only scored conformance failure (client leg 49/50).
The transport already sends the standard headers on the modern path; what was missing is
a driver that exercises each request kind the harness observes. The new case issues `tools/list`,
`tools/call` (the `test_headers` fixture tool), `resources/list`, `resources/read`
(the first listed resource URI), `prompts/list`, and `prompts/get` (the first listed prompt),
letting the harness see the `Mcp-Method` header on every POST and the `Mcp-Name` header on
the three name-carrying requests. The `initialize` / `notifications/initialized` rows stay SKIPPED
by design: the 2026-07-28 wire is stateless, so those requests are never sent.
## How Has This Been Tested?
`conformance client --scenario http-standard-headers --spec-version 2026-07-28` passes 9/9 checks,
and the full `--requirements 2026-07-28` client leg exits 0 with every scored scenario passing
(435 checks; the only failures are the baselined not-scored DPoP/WIF extensions). RuboCop is clean.
## Breaking Changes
None. The change only adds a scenario driver to the conformance client fixture; library code is untouched.
## 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
The `json-schema-2020-12-preservation` client scenario (SEP-1613, SEP-2106; not scored for
2026-07-28, as it was added after the requirements anchor) failed only because the conformance client
had no branch for it: an unknown scenario aborts after connecting, so the harness never observed
a `tools/list` nor the schema echo it diffs.
The client itself already preserves the vocabulary the scenario checks (`$schema`, `$defs` with
`$anchor`, `$ref`, `allOf`/`anyOf`, `if`/`then`/`else`, `additionalProperties`):
`Client::Tool#input_schema` is the parsed `tools/list` response untouched, with no dereferencing,
validation-time rewriting, or keyword stripping, the same fixture-echo approach
the TypeScript and Python conformance clients take. The new branch lists the tools and echoes
the focal tool's observed `inputSchema` verbatim through `json_schema_echo`,
and the expected-failures entry is removed.
## How Has This Been Tested?
The scenario passes 9/9 at its default 2025-11-25 wire. On top of the modern-lifecycle client selection
(the SEP-2322 driver branch) it also passes 9/9 at `--spec-version 2026-07-28`, where the SEP-2106 keyword checks
are failure-gated instead of skipped. `bundle exec rake` is green, with the conformance task passing its baseline check
on both legs, and RuboCop reports no offenses.
## Breaking Changes
None. The change is confined to the conformance client and its baseline; the shipped gem is untouched.
## 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
The npm `latest` dist-tag of `@modelcontextprotocol/conformance` points at 0.1.16, the old 0.1 line,
while the 2026-07-28 requirement revisions and the frozen-requirements framework live in the 0.2.0 alpha line
(`alpha` currently resolves 0.2.0-alpha.11). The runners invoked the bare package name, so `rake conformance`
and the conformance CI job resolve whatever `latest` points at: a fresh machine silently downgrades to
the old suite while a warm npx cache may keep serving a newer one, and the suite actually in use depends
on the environment. Pinning the `alpha` dist-tag puts every run on the line the frozen conformance requirements
are anchored to (0.2.0-alpha.10, published 2026-07-27).
Running the real alpha suite surfaced the actual client-leg state, so the expected-failures baseline is
updated to match: `initialize` now passes (the harness accepts 2026-07-28, fulfilling the TODO on the old entry),
and the scenarios pending client-side work are recorded with their reasons: the 2026-07-28 stateless lifecycle
and the SEP-2322 MRTR driver, SEP-2243 custom-header mirroring, client-side JSON Schema preservation,
the DPoP and WIF authorization extensions, and one `auth/authorization-server-migration` check added to
the suite after the alpha.10 anchor.
The manual `npx` examples in `conformance/README.md` carry the same pin so local reproduction runs
the same suite as the rake task.
## How Has This Been Tested?
`npx --yes @modelcontextprotocol/conformance@alpha --version` resolves 0.2.0-alpha.11,
and `bundle exec rake conformance` passes the baseline check on both legs: the server leg reports 73 passed with 0 failed
and no expected failures, and the client leg fails exactly the scenarios the baseline records.
## Breaking Changes
None. The change is confined to the conformance tooling; the shipped gem is untouched.