The overview's Key Features and Supported Methods listed `roots`, `sampling`, and `logging/setLevel` with no deprecation marking,
while their own pages carry the SEP-2577 warnings, presenting a current-looking feature surface to a reader skimming the overview.
The five entries now carry the pages' "deprecated as of 2026-07-28" wording; `elicitation/create` stays unmarked - it is not deprecated,
and the modern lifecycle keeps carrying it as requests embedded per the multi round-trip requests pattern.
## Motivation and Context
The three conformance tasks were named `conformance`, `conformance_list`, and `conformance_server`,
spelling out with underscores the grouping that Rake expresses with namespaces.
They are now `conformance:test`, `conformance:list`, and `conformance:server`.
The bare `conformance` task is kept as an alias for `conformance:test`.
## How Has This Been Tested?
By running `rake -T` to confirm the three tasks are grouped under `conformance:`
with the alias listed beside them, `rake --dry-run default` to confirm that the default task
resolves `conformance:test`, and `rake conformance:list` to exercise a renamed task end to end.
## Breaking Changes
None. These are development-only Rake tasks.
## Motivation and Context
The runnable examples drifted from the SDK they ship with:
- POST requests whose `Accept` header does not cover both `application/json` and `text/event-stream` are answered 406,
so the cURL walkthroughs no longer worked as printed.
- `MCP::Client#connect` now defaults to `mode: :auto`, which adopts the sessionless modern lifecycle (2026-07-28)
against a dual-era server; the Streamable HTTP client example requires a session and broke against its own server.
- POST responses on an established session arrive as an SSE stream, which the hand-rolled HTTP client did not parse.
- `notification_tool` claimed to send SSE notifications but only returned a `Tool::Response`.
- `http_client.rb` read a resource URI the server never registered and skipped `notifications/initialized`.
Handshake examples now offer the latest handshake protocol version (`2025-11-25`) instead of `2024-11-05`,
the Streamable HTTP client pins `mode: :legacy` for its session-based demo, `notification_tool` reports
real `notifications/progress` events requested through `_meta.progressToken`, and the interactive client streams
those events from the POST response so they are visible on screen.
The modern lifecycle itself had no runnable example, so a new server and client pair (`modern_http_server.rb`,
`modern_http_client.rb`) shows the sessionless flow end to end: `server/discover` capability discovery,
the per-request `_meta` envelope with the `Mcp-Method` and `Mcp-Name` headers (stamped by the SDK client automatically),
`resultType` stamping, SEP-2549 cache hints, a SEP-2322 multi round-trip `deploy` tool resumed automatically by
the client's elicitation handler, and the removal of legacy-only methods such as `ping`. The same server keeps serving
the legacy `initialize` flow, routed per request by the `MCP-Protocol-Version` header. `examples/README.md` documents
the pair and adds a cURL walkthrough covering the modern headers, the envelope, and the multi round-trip exchange.
## How Has This Been Tested?
- `ruby examples/stdio_client.rb` against `stdio_server.rb`
- `ruby examples/http_client.rb` against `http_server.rb`
- `ruby examples/streamable_http_client.rb` against `streamable_http_server.rb`, including the progress stream
- `ruby examples/modern_http_client.rb` against `modern_http_server.rb` (discover, modern adoption, cache hints,
`resultType`, the multi round-trip resume, `ping` rejection)
- The cURL walkthroughs in the banners and `examples/README.md`, including the `requestState` echo and a legacy `initialize`
against the modern example server
## Breaking Changes
None.
This release repairs the `subscriptions/listen` stream: a host that buffers its responses,
the documented Rails controller pattern among them, can decline the method with
`serve_subscriptions_listen: false` instead of answering a modern client with a streaming body
it cannot render, and the stream's ordering guarantees are enforced - no notification before
the acknowledgement, none after the graceful closing result.
The new keyword argument is what makes this a minor release; everything else is a bug fix.
The controller-pattern crash is a regression from 1.2.0, where the modern lifecycle began
answering `subscriptions/listen` that earlier versions had refused as an unknown method,
and it fails quietly: only the notification stream breaks, so tool calls keep working.
## 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 documentation site under `docs/` had no development command: previewing a change meant hand-assembling
a Jekyll environment, since the site only builds for real when a release publishes it through
`bin/generate-gh-pages.sh` and the GitHub Pages runtime. `rake docs:preview` now builds and serves
the site at http://localhost:4000 (override with `PORT`), with watch-based rebuilds on edit.
The preview dependencies live in their own `docs/Gemfile` rather than the gem's Gemfile:
`github-pages` mirrors the runtime that builds the released site (jekyll-remote-theme,
jekyll-redirect-from, and the Jekyll version Pages actually runs), which keeps the preview faithful
but is far too heavy to impose on the gem itself. The task subshells into that bundle with
`Bundler.with_unbundled_env`, the same delegation shape the conformance tasks use for npx.
Two support pieces keep the pinned toolchain running on current Ruby. `docs/_preview/taint_shim.rb` restores
the taint API as no-ops: Liquid 4.0.3 calls `obj.tainted?` on every variable render,
ahead of its own lax-mode check (`liquid/variable.rb:124`), so on Ruby 3.2 and later,
where the taint API was removed, the first layout render dies with NoMethodError, while on Ruby 3.1
and earlier the methods still exist as deprecated no-ops and the shim is inert. The shim is loaded only into
the preview process via RUBYOPT. The former default gems the pinned Jekyll expects are listed in the docs Gemfile.
The task also regenerates `docs/_data/versions.yml` from the version tags, mirroring what `bin/generate-gh-pages.sh`
produces at deploy time, so the nav footer shows the released-gem version line exactly as it does on the published site.
Build products and the generated data file are ignored.
## How Has This Been Tested?
By running the task on a spare port and verifying the served pages, the footer's version line,
and that a SIGINT stops it cleanly with exit 0; `git status` stays clean while it runs (all build products ignored).
Verified on both Ruby 3.1.5 (shim inert) and Ruby 4.0.6, where a shimless build reproduces the NoMethodError
and the task runs green with the shim. RuboCop and the full suite are green.
## Breaking Changes
None. The task and its Gemfile are development-only additions; the gem and its dependencies are untouched.
## 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.
The bottom of the sidebar (and the mobile page footer) showed the theme's fallback line
"This site uses Just the Docs, a documentation theme for Jekyll", which tells readers nothing about the SDK.
Overriding nav_footer_custom.html replaces it with the release the documentation describes, linking the RubyGems
page of the version deployed via _data/versions.yml by bin/generate-gh-pages.sh, and with the licensing:
the documentation is CC-BY-4.0 per LICENSE, while the code licensing is delegated to LICENSE itself,
whose consent-based MIT to Apache-2.0 transition does not fit a footer phrase.
Without versions data (local previews of the docs/ source) only the license sentences render.
## 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.
Three pages carried names the 2026-07-28 specification does not use: "Multi-Round-Trip Results" for
the pattern the specification titles Multi Round-Trip Requests (MRTR) and serves at basic/patterns/mrtr -
the results are the `InputRequiredResult` wire type, not the feature - plus "Completions" for the utility
the specification titles Completion, and "Notification Subscriptions" for the pattern it titles Subscriptions.
Retitle the three pages to the specification's names, move them to /server/mrtr/ and /client/mrtr/,
/server/completion/, and /server/subscriptions/ following the specification's slugs, update every internal reference,
and keep the URLs published with v1.3.0 working via jekyll-redirect-from.
The Discovery page keeps its title but moves from /server/discovery/ to /server/discover/, matching
the specification's slug and the `server/discover` RPC, with its published URL redirected the same way.
This release adds a `resources_list_handler` so `resources/list` can be served per-connection,
passes handler-returned `_meta` through subscription results, and moves the user-facing documentation
from README.md to the documentation site at https://ruby.sdk.modelcontextprotocol.io.
Two entries under "Changed" are incompatible with 1.2.0 and ship in a minor release under
the exceptions in VERSIONING.md: OAuth endpoint responses larger than 4 MiB are refused,
and a request whose id is already in flight on the same session is answered with Invalid Request.
IMPORTANT: The documentation moves with #523, and the site deploys only through the release workflow,
so #523 needs to be merged immediately before this release.
Move the documentation that had grown to 3000+ lines in README.md onto https://ruby.sdk.modelcontextprotocol.io
and slim the README down to a quick start and feature overview, following the Python SDK layout:
- Add 21 server pages as the docs/_server/ collection (overview plus transports, discovery, tools, prompts, resources,
roots, sampling, elicitation, multi round-trip results, notifications, notification subscriptions, cancellation, progress,
ping, completions, logging, pagination, server context, configuration, and custom methods), 8 client pages as
the docs/_client/ collection (overview, transports, lifecycle, multi round-trip results, cancellation, ping, pagination,
and authorization), and 3 extension pages as the docs/_extensions/ collection (overview, capability extensions, and MCP Apps,
following the Extensions Overview recommendation that SDK documentation list the supported extensions) -
one page per topic, ordered to match the sidebar of the 2026-07-28 specification, with the client-side APIs
(pinging, cancelling, and paginating from MCP::Client) documented under Building Clients
- Add top-level Examples and Protocol Versions pages after Installation, linking the runnable examples in examples/
and summarizing the supported protocol versions, the era model, and client negotiation
- Render the three sections as just-the-docs collections, which list every page beneath a plain category heading in
the sidebar with no folding, and serve every page at an extensionless URL under /server/, /client/, and /extensions/,
matching the sibling SDK documentation sites
- Replace docs/building-servers.md and docs/building-clients.md with redirects to the new section overview pages via
jekyll-redirect-from, redirect the previously published /installation.html to its extensionless URL, fold their content
that was missing from README.md into the new pages, and update the Tool argument keys reference comment in lib/mcp/server.rb to
the relocated Tools page
- Reduce README.md to the badges, a compact feature overview in the Python SDK style, installation instructions, a stdio
server and client quick start, and a License section, using absolute URLs for the remaining repository links
so they resolve on rubygems.org
- Adapt formatting where GitHub rendering habits break on the site: convert the numbered "three ways to define" lists into headings,
since kramdown restarts numbering at 1 when code blocks split list items, convert GitHub-style alerts into just-the-docs callouts
with the SEP-2260 server-to-client association note raised to a red warning, merge the duplicated Exception Reporting
and Configuration Block Data sections into the Configuration page, and relocate the Streamable HTTP settings that were nested under
the Logging section into the Transports page
- Refresh the migrated content against the current implementation: correct stale claims and broken samples the README carried,
note on each session-era feature how it relates to the modern lifecycle of MCP 2026-07-28, and point spec links at the latest revision,
keeping deliberate 2025-11-25 pins for pages the modern revision removed
- Style the site after the Rails API documentation palette (red links and accents on neutral surfaces) with matching
light and dark color schemes, center the sidebar and content as one block, add a Previous/Next footer pager following
the sidebar order, open external links in a new tab, and serve the MCP logo as the favicon
Every code block and heading from the previous README was verified to have a home in the new docs pages or the slimmed README before
the reduction; a few samples were corrected rather than copied, as noted above.
The Multi-Round-Trip Results section still said "automatic resumption is not implemented yet",
but the client driver shipped in #500: with a handler registered through `on_elicitation`,
`on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, and `read_resource` methods fulfill
embedded `inputRequests` and re-issue the original request automatically,
raising `MCP::Client::InputRequiredError` only when no matching handler exists.
Rewrite the paragraph to describe the implemented behavior, including the `input_required_max_rounds` cap
and the keywords for manual driving.
## 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.