Tool handler exceptions and anything reaching JsonRpcHandler's blind
rescue had their #message echoed into the error data member, exposing
internals to untrusted clients (CWE-209). Capability errors keep their
client-facing message by re-raising through RequestHandlerError, the
one channel whose message is deliberately surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Warn when Roots, Sampling, or Logging APIs are used with MCP protocol version 2026-07-28 or newer.
Add a shared protocol deprecation helper, YARD deprecation notes, and regression tests covering warnings at and after the deprecation threshold and no warnings for older or never-established protocol versions.
Closes https://github.com/modelcontextprotocol/ruby-sdk/issues/390
Co-authored-by: Qlaw <noreply@qlaw.quick.shopify.io>
## Motivation and Context
Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first,
like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside
every supported list enters a new sessionless modern path, while requests without the header (or with
a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path
means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }`
instead of a generic invalid-request error.
Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model,
and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates
by request shape: an `Mcp-Session-Id` binds the request to an established legacy session
(POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST
whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over
the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic
(`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request,
since Rack 3 inputs need not be rewindable.
The modern path (`handle_modern`) is a single POST/JSON exchange:
- GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405.
- It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`,
and rejects requests carrying one with HTTP 400.
- Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path.
- Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against
the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent.
`Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII.
- HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400,
`-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else
including internal errors stays 200.
- Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`,
whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of
broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`.
The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple,
which would previously fall through the legacy path via the header default. Session-bound under
a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because
the session already negotiated the legacy lifecycle (mirroring the stdio era lock).
With the header missing or naming a stable-only version, it violates the header/body match
requirement and is rejected as a header mismatch (`-32020`).
Intentional behavior changes for previously-erroneous requests, all following from header-primary routing:
an unknown header version now yields `-32022` with data instead of the legacy `-32600` message
(`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version,
or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed
on the legacy path. Existing tests were updated to codify these.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list
for unknown header versions, `-32020` for header/body version, `Mcp-Method`,
and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection,
405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`),
`-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without
an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.
Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy
and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path,
a session-bound POST carrying the modern envelope is rejected as `-32600`,
and an oversized modern POST returns 413.
## Breaking Changes
None for conforming clients: requests without the header or with a stable-only version
are byte-identical to before, and legacy clients negotiating 2026-07-28 over
the classic handshake connect unchanged. Requests that were already rejected change error shape
(see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
## Motivation and Context
Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.
- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.
`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.
## Breaking Changes
None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
## Motivation and Context
Clients that require MCP 2026-07-28, such as ChatGPT, reject connections because the SDK
does not advertise that version: `Server#init` answers a 2026-07-28 request with 2025-11-25,
the Streamable HTTP transport 400s an `MCP-Protocol-Version: 2026-07-28` header,
and `server/discover` omits the version from `supportedVersions`.
Making 2026-07-28 the latest entry in `SUPPORTED_STABLE_PROTOCOL_VERSIONS` fixes all three at once,
since they all read that list. The version now serves both lifecycles of the SEP-2575 dual-era model:
negotiable through the legacy `initialize` handshake, and simultaneously the version of
the per-request modern lifecycle.
Parts of the 2026-07-28 feature set remain unimplemented, such as the stateless modern lifecycle
over the transports, and are being built separately. This change deliberately limits itself to
the version bump, focusing first on eliminating the connection errors that 2026-07-28 clients hit.
Known follow-up: the in-progress modern HTTP path routes requests to the sessionless modern lifecycle
when the header version is absent from `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, so that routing predicate
needs rework on top of this change.
Closes#474
## How Has This Been Tested?
New regression tests: `initialize` with `protocolVersion: "2026-07-28"` echoes it back (`server_test.rb`),
a follow-up POST with `MCP-Protocol-Version: 2026-07-28` passes header validation
(`streamable_http_transport_test.rb`), and `Configuration.new(protocol_version: "2026-07-28")` is accepted
(`configuration_test.rb`).
`bundle exec rake test` passes (1454 runs, 0 failures). The conformance server suite passes 30/30.
The client `initialize` scenario is added to the expected-failures baseline: the conformance framework pins
the client's requested version to 2025-11-25 and does not accept 2026-07-28 yet; the entry carries a TODO
to remove it when the framework catches up.
## Breaking Changes
Servers that do not pin `configuration.protocol_version` now answer version negotiation fallbacks
and default client requests with 2026-07-28 instead of 2025-11-25. 2025-11-25 remains fully supported
and negotiable; clients that request it are unaffected.
## Motivation and Context
Second step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release, building on the `MCP::RequestEnvelope` foundations.
`Server#handle_request` now lifts the per-request `_meta` envelope before dispatch: a request
whose `_meta` carries the full required triple is validated (raising `-32022` with
`data: { supported:, requested: }` for unsupported versions) and its envelope is threaded into
`tools/call`, `prompts/get`, `completion/complete`, `resources/read`, subscribe/unsubscribe,
and custom method handlers via `MCP::ServerContext`. A partial triple keeps flowing through
the legacy path untouched, so existing `_meta` usage (`progressToken`, trace context) is unaffected.
`MCP::ServerContext` gains per-request readers and a capability guard:
- `modern?`, `protocol_version`, `client_info`, and `client_capabilities` read from the envelope first
and fall back to session state stored by `initialize` on legacy requests. The envelope always wins
because servers MUST NOT infer client state from prior requests; nothing is written to the session
on the modern path.
- `require_client_capability!(*path)` raises `Server::MissingRequiredClientCapabilityError`
(`-32021` with `data: { requiredCapabilities: }`) when the request did not declare the capability.
- `notify_log_message` honors the per-request `io.modelcontextprotocol/logLevel`: on modern requests without it,
the server MUST NOT send `notifications/message`, and an insufficient level drops the message the same way.
`MCP::ServerSession` gains the connection-era lock of the dual-era serving model: `era` is `nil`
until the first era-distinctive message succeeds, a successful `initialize` locks `:legacy` as
a side effect of `mark_initialized!`, `lock_era!` refuses to flip an established era, and transports can construct
per-request sessions with `era: :modern`. On a modern-locked session, `initialize` is rejected with `-32022`
(the modern lifecycle has no handshake) and the envelope triple becomes required for every other request
except `server/discover`.
The existing plain `RuntimeError` raises in `ServerSession#list_roots` and friends are intentionally unchanged:
converting them to `RequestHandlerError` subclasses would break callers rescuing `RuntimeError`.
Typed `-32021` errors are scoped to the new envelope-based guard.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover the wire behavior through `Server#handle`: envelope data exposure
without session mutation, `-32022` for unsupported envelope versions, partial triples staying legacy,
the envelope requirement and `initialize` rejection on modern-locked sessions, the `server/discover` exemption,
`require_client_capability!` returning `-32021` with the `requiredCapabilities` data shape,
and the `ServerSession` era-lock transitions (including preset `era: :modern` and invalid values).
New tests in `test/mcp/server_context_test.rb` cover the per-request logLevel gate (absent, insufficient,
and sufficient levels), the envelope-first/session-fallback readers, and string-keyed capability matching
in `require_client_capability!`.
## Breaking Changes
None. All new keyword arguments default to `nil`, legacy requests take exactly the same code path as before,
and the era lock only constrains sequences that were previously impossible (modern-era traffic).
Fix a Ruby 2.7 ArgumentError When Raising UnsupportedProtocolVersionError
## Motivation and Context
On Ruby 2.7, a method that declares a keyword parameter splits a trailing
symbol-keyed Hash positional argument into keywords. `UnsupportedProtocolVersionError#initialize`
declared `supported:`, so passing the request Hash as the second positional argument raised
`ArgumentError: unknown keywords: :name, :arguments, :_meta` inside the error constructor,
and the intended `-32022` response surfaced as a `-32603` internal error on the Ruby 2.7 CI job.
No caller overrides `supported:`; drop the keyword parameter and read
`Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS` directly, matching the keyword-free
shape of `ResourceNotFoundError` and `MissingRequiredClientCapabilityError`.
## How Has This Been Tested?
- Reproduced the `-32603` responses on Ruby 2.7.8 and confirmed both now return `-32022`
- Added a regression test constructing the error with a symbol-keyed request Hash
- `bundle exec rake test` on Ruby 2.7.8: 1467 runs, 0 failures
## Breaking Changes
None. The `supported:` keyword argument was never passed by any caller.
## Motivation and Context
Opening https://ruby.sdk.modelcontextprotocol.io/ briefly showed a black half-circle
at the bottom of the screen. The theme preference script in `head_custom.html` called
`window.jtd.setTheme` on every page load. That helper rewrites the `href` of
the render-blocking color stylesheet link, which detaches the loaded stylesheet
and refetches it, so the page paints unstyled until the replacement arrives.
During that window the search magnifier SVG, sized only by CSS, rendered at its fallback size
and appeared as a large black half-circle near the bottom of the viewport.
The site sets no `color_scheme`, so the default stylesheet is identical to the light one
and most visitors paid the flash for a no-op swap.
This change replaces the `jtd.setTheme` call with a local swapper that:
- Leaves the stylesheet untouched when the resolved theme already matches
(treating `default` as `light`).
- Swaps non-destructively otherwise: the new link is inserted next to the current one
and the old link is removed only after the new stylesheet has loaded. On error the new link
is dropped and the page keeps its current styles.
- Sets `blocking="render"` on the new link so supporting browsers hold first paint until
the dark stylesheet arrives.
It also pre-applies the dark background and text colors in `custom.scss`, keyed on
the `data-theme` attribute that is set synchronously in the head, to soften the light-colored
first paint for dark-preference visitors in browsers without `blocking="render"` support.
## How Has This Been Tested?
Mirrored the live site locally, patched the inline script with the new code,
and drove it in Chrome:
- Unpatched baseline reproduced the old behavior: the link `href` was rewritten
and `just-the-docs-light.css` was fetched in addition to `just-the-docs-default.css`.
- Patched light and system-light loads keep the original link untouched with no extra stylesheet request.
- Patched dark loads settle on a single `just-the-docs-dark.css` link, with `jtd.getTheme()`
still reporting the right theme.
- Rapid toggling keeps at most two color stylesheet links and settles on exactly one matching
the final preference.
- With `just-the-docs-dark.css` returning 404, the failed link is dropped and the page stays styled.
## Breaking Changes
None.
Client#tools/#list_tools built Tool objects without an annotations
field even though the server includes annotations in every
tools/list response. Add the reader and pass the raw hash through,
mirroring how output_schema is already handled.
## Motivation and Context
The SDK tiering system (SEP-1730) requires Tier 1 SDKs to document all non-experimental features
with examples. A documentation coverage audit against the canonical feature list found that the README.md
only showed `type: "text"` content everywhere, leaving several implemented features invisible to users:
- Tool responses with image, audio, and embedded resource content (`MCP::Content::Image` / `Audio` / `EmbeddedResource`)
- Prompt messages carrying image or embedded resource content
- Reading binary resources via `MCP::Resource::BlobContents`
- Reading resources through a registered resource template URI
- Elicitation `requested_schema` flat-object restrictions and client-side validation behavior
- Elicitation default values (SEP-1034) and enum values with `enumNames` (SEP-1330)
All of these are existing SDK capabilities; the conformance suite already exercises them.
This change documents each with prose and a runnable-style example, closing every documentation gap
that does not require new code. Legacy SSE transport remains undocumented because the SDK intentionally
does not implement it.
## How Has This Been Tested?
Documentation-only change. Every code example was written against the current public API in lib/
(`MCP::Content`, `MCP::Resource::TextContents` / `BlobContents`, `MCP::Prompt::Message`, `create_form_elicitation`)
and cross-checked with the working implementations in conformance/server.rb, which pass the conformance suite.
## Breaking Changes
None. This only adds sections to README.md.
## Motivation and Context
SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release) replaces
the `initialize` handshake with a stateless "modern" lifecycle: every request carries its protocol version,
client info, and client capabilities in reserved `_meta` keys, and the server validates each request independently.
This change lays the shared vocabulary for that lifecycle without changing any behavior:
- `MCP::Configuration` gains `LATEST_MODERN_PROTOCOL_VERSION` ("2026-07-28"), `SUPPORTED_MODERN_PROTOCOL_VERSIONS`,
and `Configuration.modern_protocol_version?`. Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS`
so `initialize` can never negotiate them and `protocol_version=` keeps rejecting them.
- `MCP::ErrorCodes` gains `HEADER_MISMATCH` (-32020), completing the spec's stateless lifecycle error code block alongside
the existing -32021/-32022 constants.
- New `MCP::RequestEnvelope` parses and validates the per-request `_meta` triple (`io.modelcontextprotocol/protocolVersion`,
`clientInfo`, `clientCapabilities`, plus the optional deprecated `logLevel`). A request is classified as modern only
when the full triple is present, matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`,
so partial or legacy `_meta` entries keep flowing through the legacy path untouched.
- New typed errors `Server::UnsupportedProtocolVersionError` (-32022 with `data: { supported:, requested: }`) and
`Server::MissingRequiredClientCapabilityError` (-32021 with `data: { requiredCapabilities: }`) reuse the established `RequestHandlerError`
`error_code`/`error_data` plumbing (the -32042 URL elicitation precedent).
Nothing dispatches through `RequestEnvelope` yet; the server-side modern handling, transport routing, and client support build on
this in follow-up changes.
Refs #389.
## How Has This Been Tested?
New `test/mcp/request_envelope_test.rb` covers the modern-triple classification (full triple, partial triple, string keys,
legacy `progressToken`), parsed field access, the -32022 error data shape, and invalid-request errors for incomplete or mistyped triples.
`test/mcp/configuration_test.rb` asserts the new constants, the predicate, and that modern versions stay rejected by `protocol_version=`.
`test/mcp/server_test.rb` asserts both typed errors surface on the wire with their SEP-2575 codes and data through `Server#handle`.
`test/mcp/error_codes_test.rb` pins -32020.
## Breaking Changes
None. Constants and classes are additive; no request path changes behavior.
This first stable release marks the public API as stable per SEP-1730:
breaking changes now ship only in major releases, with the narrow exceptions
documented in VERSIONING.md. The release adds ROADMAP.md and VERSIONING.md
and contains no behavior changes.
Future feature proposals can be incorporated without breaking changes.
No incompatibilities have been identified with the known issues and pull requests.
## Motivation and Context
The gemspec already declares `required_ruby_version >= 2.7.0`, but the conformance Rake task used `Hash#except` to drop
the `:port` option before handing the remaining options to `Conformance::ClientRunner`. `Hash#except` was only added in Ruby 3.0,
so running `rake conformance` on Ruby 2.7 aborted before any scenario ran, raising `NoMethodError: undefined method except for {}:Hash`.
CI missed this because no job runs `rake conformance` on Ruby 2.7: the conformance workflow runs only on Ruby 4.0,
while the Ruby 2.7.0 matrix entry runs `rake test` alone.
## How Has This Been Tested?
Replace `options.except(:port)` with `options.reject { |key, _| key == :port }`, which has been available since well
before Ruby 2.7 and returns an equivalent Hash without mutating the original. `rake conformance` now runs the full suite on Ruby 2.7,
and its baseline still passes on current Ruby, verified on Ruby 4.0.
## Breaking Changes
None.
## Motivation and Context
The SDK tiering system (SEP-1730) requires Tier 1 SDKs to publish a clear
versioning policy with a documented breaking-change policy. The CHANGELOG
already states that the project adheres to Semantic Versioning, but there
was no standalone document describing what counts as a breaking change,
how the 0.x phase differs from post-1.0.0, or how incompatibilities are
communicated to users.
One policy decision is worth calling out: minor releases avoid incompatible
changes as much as possible, but fixes for incorrect MCP spec conformance,
for security vulnerabilities, and for clear defects (behavior contradicting
the documentation, crashes, or data corruption) may ship in a minor release
even when they are behavior-breaking. The SDK's primary contract is
conformance to the MCP specification, so behavior that deviates from
the spec is treated as a bug rather than an API guarantee, and preserving
compatibility with a clear defect would mean preserving the defect itself.
## How Has This Been Tested?
Documentation-only change; there is no code to exercise. The referenced
files (CHANGELOG.md, GitHub Releases) and their formats were verified
against the current repository state.
## Breaking Changes
None. This adds a new top-level Markdown document and touches no code.
## Motivation and Context
The SDK tiering system (SEP-1730) requires a published roadmap for both Tier 1 and Tier 2.
Until now the Ruby SDK had no roadmap document, which left users without visibility into
upcoming feature support and blocked the tiering requirement.
This document records the SDK's current conformance status (all server and client scenarios passing),
the API stability policy backed by Semantic Versioning and VERSIONING.md,
the handling of features deprecated by the 2026-07-28 spec revision (SEP-2577),
and how new MCP spec revisions will be tracked. Legacy SSE transport is explicitly called
out as out of scope so users are not left guessing about it.
## How Has This Been Tested?
Documentation-only change; there is no code to exercise. The conformance figures cited in
the roadmap were taken from the current SEP-1730 tier assessment run against the latest main.
## Breaking Changes
None. This adds a new top-level Markdown document and touches no code.
## Motivation and Context
Negative auth scenarios such as `auth/resource-mismatch` and `auth/scope-retry-limit` are
designed to drive the client into a terminal failure: the SDK correctly refuses
a mismatched protected-resource metadata `resource` and stops after the scope step-up retry limit.
The conformance harness asserts on the observed protocol exchange and already marks these scenarios as passed,
but the client script had no top-level rescue, so each run leaked a raw Ruby backtrace to stderr inside a noisy
"Client exited with code 1" block.
This change wraps the connect and scenario dispatch in a rescue that reports the expected `AuthorizationError`,
`RequestHandlerError`, and `Faraday::Error` outcomes as a concise one-line reason on stderr and exits with status 0.
The harness echoes the client's output only when the exit status is non-zero and does not consult the exit status
for its verdict, so the expected failures no longer produce any output block at all; the one-line reason remains
visible in manual runs and in the harness `--output-dir` stderr artifact. Any unexpected error still raises with
a full backtrace and a failing exit status so genuine SDK bugs remain visible.
## How Has This Been Tested?
Ran `bundle exec rake conformance`: the server suite passes 40/40 and the client suite passes 280/280
with no "Client exited with code" or "Stderr:" blocks in the output. Exercised `auth/resource-mismatch`
and `auth/scope-retry-limit` individually via `Conformance::ClientRunner`; both pass with clean output.
## Breaking Changes
None. Only the conformance client script output changes; no library code is affected.
## Motivation and Context
When two PRs are merged to main in quick succession, the Conformance Tests run for
the earlier merge commit is cancelled by the run for the later one.
On push events, `github.ref` is always `refs/heads/main`, so every merge to main shares
the same concurrency group and `cancel-in-progress: true` cancels all but the latest run.
As a result, only the last merged PR gets a conformance result.
This change appends `github.sha` to the concurrency group for push events so that
each merge commit runs independently. For pull_request events the SHA part is empty,
keeping the existing behavior where pushing new commits to a PR cancels its superseded runs.
## How Has This Been Tested?
Validated the workflow YAML syntax locally. The concurrent behavior itself can only be
observed on GitHub Actions, by confirming that both Conformance Tests runs complete
when two pushes land on main within a short interval.
## Breaking Changes
None.
## Motivation and Context
The Rack 3 SPEC requires response header names to be lowercase.
https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section:
> Header keys must not contain uppercase ASCII characters (A-Z).
https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers
However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`.
Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response
from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance.
HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected;
only in-process consumers that read the raw Rack headers hash see the new keys.
Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names
a protocol error on HTTP/2, so proxies and h2-capable servers already require it.
The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`,
so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention,
not a wire requirement.
Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name
a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers,
which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as
an `Access-Control-Expose-Headers` value, which browsers match case-insensitively;
and README/docs prose follows the MCP specification's documentation convention.
Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys.
AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing
invites reintroducing capitalized names in future transport work.
## How Has This Been Tested?
Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses.
Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop,
and rake conformance.
## Breaking Changes
Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport,
with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are
unaffected because header names are case-insensitive on the wire.
## Motivation and Context
`MCP::Client::HTTP` buffers server bytes with no upper bound in two places:
- SSE responses are parsed incrementally, but the parser accumulates `data:` lines until
a blank line dispatches the event, so a server that never terminates an event grows
the buffer indefinitely. The abort-on-response signal does not help here, since it only fires
after a complete response event has been dispatched.
- Non-SSE (JSON) bodies accumulate in `SSEStream#buffer` chunk by chunk with no limit.
Both the POST response stream and the standalone GET listening stream are affected.
The stdio client already bounds server frames (`max_line_bytes:`, 4 MiB default)
and the server transports bound request bodies (`max_request_bytes:`, 4 MiB default);
this brings the HTTP client in line.
`MCP::Client::HTTP.new` gains `max_message_bytes:` (default 4 MiB), which caps the bytes buffered
for a single message - an SSE event or a JSON response body. External byte counting cannot tell
consumed-and-discarded bytes (comments, field names, delimiters) from consumed-and-retained ones,
so the check measures the parser's own String buffers after each chunk. An over-limit message raises
`RequestHandlerError` on the request path and stops the GET listening stream instead of reconnecting to
the same over-limit event.
## How Has This Been Tested?
New tests in `test/mcp/client/http_test.rb` cover the SSE and JSON rejection paths end-to-end,
the per-event reset (events that individually fit must not count against later ones),
the env-less `on_data` fallback, and the constructor validation. `bundle exec rake`
(tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
Responses larger than 4 MiB are now rejected by default; pass a larger g`max_message_bytes:` to accept them.
## Motivation and Context
The MCP specification defines `sampling/createMessage` as a server-to-client request
that lets a server ask the client to generate an LLM completion, so the server can leverage
the client's model access without its own API keys.
The Ruby SDK already supports the server side (sending the request) and, on the client side,
the generic server-to-client request infrastructure introduced for elicitation
(the standalone GET SSE listening stream and `on_server_request` dispatch).
This adds the client-side `sampling/createMessage` handler on top of that infrastructure.
## Changes
- Add `MCP::Client#on_sampling`, a thin wrapper that registers a `sampling/createMessage` handler
via `transport.on_server_request`, mirroring `on_elicitation`. The handler receives the request params
and returns a `CreateMessageResult`-shaped Hash sent back as the JSON-RPC result.
- Document client-side sampling in the README, including the `sampling` (and `sampling.tools`)
capability declaration and the human-in-the-loop guidance from the specification.
## How Has This Been Tested?
- Unit tests for `on_sampling` covering handler registration on the transport
and the error raised when the transport does not support server-to-client requests.
- An HTTP transport test that dispatches a `sampling/createMessage` server request delivered on
the SSE stream and returns a `CreateMessageResult`.
## Deprecation Note
SEP-2577 deprecates sampling (along with roots and logging) starting with protocol version `2026-07-28`.
The deprecation warnings are added in https://github.com/modelcontextprotocol/ruby-sdk/pull/406.
Sampling remains fully supported under the current `2025-11-25` protocol version and stays available
throughout the spec's deprecation window, so the client side is implemented here for parity with
the server side and with the Python and TypeScript SDKs.
`MCP::Client#on_sampling` carries the same `@deprecated` annotation as the server-side senders
annotated in #429, tailored to the receiving side: the handler exists to interoperate with servers
that still send sampling requests during the deprecation window. The README section carries
the same note.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling