APM integrations need to tag a span with the subject of the request, but the
user-defined `server_context` hash passed to `Server.new` never reaches
`around_request` or `instrumentation_callback`. The exception reporter's
`server_context` argument is a different value — it describes where a failure
occurred (`{ request: ... }`, `{ notification: ... }`), not who made the call —
so there is currently no way to read the request subject from a hook.
Add `Configuration#instrument_server_context`, off by default. When enabled,
`instrument_call` puts the host's `server_context` into the instrumentation data
before invoking `around_request`, so it is readable both before and after
`request_handler.call`.
The flag is opt-in rather than unconditional because the hash is
application-supplied and may hold values a tracing backend should not receive.
Hosts that do not expose a `server_context` reader are unaffected.
`instrument_call` reads it through `self.server_context`: the existing
`server_context:` keyword shadows the reader, and the value wanted here is the
user-defined hash, not the reporter context.
Follows the same shape as the earlier additions of `tool_arguments` (#218) and
`client` (#221) to this hash.
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.
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
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 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
## Motivation and Context
This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems.
Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer
disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`,
and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense
from the new `Style/ReduceToHash` cop.
This change fixes them as follows:
- Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7,
where Regexp literals are not frozen.
- Remove inline disable directives that became redundant.
- Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names
can reference class names such as `NoMethodError`.
- Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test
since Mocha cannot stub methods on frozen objects.
- Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`.
Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0
## How Has This Been Tested?
`bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The conformance suite also passes against its baseline.
## Breaking Changes
None. Freezing these constants only affects code that mutates them in place, which has never been supported.
## Motivation and Context
`StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`,
which matches on the method name alone. An `initialize` message sent without an id (framed as a notification)
therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized,
but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil.
Two things then went wrong:
- `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response,
so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`)
and the client gets a malformed response or a 500.
- The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized.
The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions
toward the `max_sessions` cap until the reaper reclaims them.
`handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session
and acks with 202, mirroring how `handle_regular_request` already handles a nil response.
A well-formed `initialize` request (with an id) is unaffected.
## How Has This Been Tested?
A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize`
with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map.
It fails against the previous code (a 200 with a nil body element and a retained session) and passes now.
## Breaking Changes
None. A conforming client sends `initialize` as a request with an id and is unaffected;
only the malformed id-less form changes, from a broken 200 to a 202 with no retained session.
## Motivation and Context
In `StreamableHTTPTransport#handle_post`, the response and regular-request branches reject
a message whose session no longer exists (the response branch via `session_exists?`,
the regular path via `validate_and_touch_session`), but the notification branch dispatched unconditionally.
A notification carrying a present-but-unknown (or expired-but-not-yet-reaped) `Mcp-Session-Id`
therefore passed the `missing_session_id` gate and the ownership gate (which returns true
when the session does not exist) and reached `dispatch_notification` with no session,
running against the shared `Server` instead of a live `ServerSession`. With the default no-op notification
handlers this had no visible effect, but a custom notification handler would then run without
a live session behind it.
`handle_post` now checks `session_active?` in the notification branch, mirroring the other two branches.
`session_active?` rejects an unknown or expired session (evicting the expired one) without extending its lifetime,
so a stale session ID yields `404 Session not found` and a live session is dispatched exactly as before.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` assert that
a notification with an unknown session ID is rejected with `404 Session not found`
and that a notification carrying a live session ID is still accepted with `202`.
The new rejection test fails against the previous code (it returned `202`) and passes now.
## Breaking Changes
None for a conforming client: a client that has completed `initialize` sends its notifications with
the live session ID and is unaffected. Only a notification bearing an unknown or expired session ID,
previously accepted with `202`, now receives `404 Session not found`.
## Motivation and Context
The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990,
accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization:
the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via
RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with
the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`.
The `auth/cross-app-access-complete-flow` conformance scenario exercises the complete flow and previously failed
its two core checks (the Ruby client ran the authorization-code flow instead).
This mirrors the merged TypeScript SDK implementation (`requestJwtAuthorizationGrant` / `CrossAppAccessProvider` in
typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design:
- New `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint
(`subject_token_type` id_token, `requested_token_type` id-jag, `audience` = the MCP authorization server's issuer,
`resource` = the canonical MCP server URL) and validates that the response's `issued_token_type` is an ID-JAG
before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK.
- New `MCP::Client::OAuth::CrossAppAccessProvider` declares `authorization_flow :jwt_bearer` and takes the assertion
from a callable (`call(audience:, resource:)`), so the common `IDJAGTokenExchange` case, enterprise secret stores,
and tests all plug in the same way (the TypeScript provider's assertion callback works identically).
The MCP AS credentials are stored as `client_secret_basic` client information.
- `Flow` dispatches `:jwt_bearer` alongside `:client_credentials`: discovery, issuer validation, and endpoint security checks
are shared with `run!`, then `run_jwt_bearer!` requests the assertion with the validated issuer as the audience and posts
the `jwt-bearer` grant through the existing `post_to_token_endpoint`.
- The conformance client builds a `CrossAppAccessProvider` when the harness injects `idp_id_token` (exchanging it at
`idp_token_endpoint` with `idp_client_id`, like the TypeScript and Python conformance clients),
and `auth/cross-app-access-complete-flow` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
- New `test/mcp/client/oauth/id_jag_token_exchange_test.rb` asserts the exact RFC 8693 form parameters on the wire
and the response validation: a non-ID-JAG `issued_token_type` is rejected, as are a missing `access_token`,
non-2xx responses, unparseable bodies, and non-object JSON.
- New `test/mcp/client/oauth/cross_app_access_provider_test.rb` covers the provider surface:
stored `client_secret_basic` credentials, the `:jwt_bearer` flow declaration, audience/resource passthrough to
the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider),
and storage delegation.
- New tests in `test/mcp/client/oauth/flow_test.rb` run the full grant against WebMock stubs: the token request carries
`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the assertion, the RFC 8707 `resource`, and HTTP Basic credentials,
with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as `audience`
and the canonical server URL as `resource`; an empty assertion aborts before the token endpoint is contacted.
## Breaking Changes
None. `IDJAGTokenExchange` and `CrossAppAccessProvider` are new, and the `Flow` dispatch only activates for providers
that declare `authorization_flow :jwt_bearer`.
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.
One of the requirements for the official SDK to move from Tier 3 to Tier 2 is reaching v1.0,
and that milestone is now quite close.
The main considerations for v1.0 are API stability and minimizing breaking changes.
Based on the work in my local branch, a path toward Tier 1 is already becoming clear from
my perspective, and this release is part of the work to prepare for Tier 2.
## Motivation and Context
SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.
The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):
- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
and never falls back to the GET stream.
Resolves#381.
## How Has This Been Tested?
- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
`create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
"attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
stays clean; their assertions are unchanged.
## Breaking Changes
None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
## Motivation and Context
Fixes https://github.com/modelcontextprotocol/ruby-sdk/issues/450
The MCP schema requires `protocolVersion`, `capabilities`, and `clientInfo` on `initialize` params,
and `uri` on `resources/subscribe` and `resources/unsubscribe` params. The server accepted requests with
these fields absent: `initialize` silently negotiated the server's default protocol version,
and `resources/subscribe` returned an empty success result. Both now return `-32602` Invalid params,
matching the Python SDK, which rejects such requests during pydantic validation.
The `uri` check runs before dispatch so it also covers handlers registered via `resources_subscribe_handler`
and `resources_unsubscribe_handler`.
A `protocolVersion` value that is present but unsupported still negotiates the server's fallback version,
as the spec's lifecycle section requires.
## How Has This Been Tested?
Reproduced the issue's stdio repro steps: both malformed requests now return `-32602` instead of succeeding.
## Breaking Changes
Clients that send `initialize` without `protocolVersion`, `capabilities`, or `clientInfo`,
or `resources/subscribe` / `resources/unsubscribe` without `uri`, now receive `-32602` Invalid params
instead of a success response. Such requests violate the MCP schema, but any code relying on
the previous lenient behavior (for example, test harnesses sending bare `initialize` requests)
must now include the required fields.
## Motivation and Context
`StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`.
The send paths wrote to an SSE stream while holding that lock: the targeted and
broadcast branches of `send_notification`, the server-to-client `send_request`,
and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream`
(a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`.
A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly,
the server-side `write` waits until the socket buffer drains.
Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking):
new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions
all wait behind one slow reader. The broadcast path holds the lock across every session,
so one slow stream also delays delivery to the sessions after it.
The cleanup paths already release the lock before touching a stream: they collect `streams_to_close`
and call `close` outside `synchronize`. This change applies the same discipline to the writes.
- Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers
the pending response there), then releases the lock before writing.
- A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`,
which mutates `@sessions` under `@mutex` and closes the affected streams outside it.
A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session,
matching the previous behavior.
- `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`;
the return values (true/false for a session, the delivered count for a broadcast) are unchanged.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records,
via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification,
broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now.
The existing send and cleanup tests still pass.
## Breaking Changes
None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
## Motivation and Context
A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised
on a non-object value parsed from an incoming frame:
- `JsonRpcHandler.handle` maps each element of a batch array through `process_request`,
which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]`
or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`,
so the error escaped; over stdio it escaped the `StdioTransport#open` read loop.
The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio.
- `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`,
which raises `NoMethodError` when the server emits a non-object frame.
Only `JSON::ParserError` was rescued, so one malformed line from the server escaped.
Both are robustness gaps: a malformed frame should be reported or skipped, not raise
an unhandled exception out of the transport.
- `process_request` now returns an `INVALID_REQUEST` error response for a non-object request
before touching `request[:id]`, so a malformed batch element becomes a normal per-element error.
- `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response,
so an unexpected handling error still produces a JSON-RPC reply.
- `read_response` skips a non-object frame the same way it skips a frame without an id.
## How Has This Been Tested?
New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements
(`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element,
and `handle_json("[[]]")` returning an error instead of raising. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and
asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb`
has the server emit non-object frames before the response and asserts the client skips them
and still returns the response.
## Breaking Changes
None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
## Motivation and Context
Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions
while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block
with manual URI routing.
`MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool`
and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`,
`size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods
and `MCP::Server#define_resource` / `#define_resource_template`.
The default `resources/read` handler now routes requests automatically: an exact URI match calls
the class-based resource's `contents` method, and class-based resource templates match
simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments.
`contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered
and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164.
## How Has This Been Tested?
- Added regression tests covering the class-level DSL, URI template matching, auto-routing,
`server_context:` opt-in, error responses, and backward compatibility of the no-op default.
- Verified end-to-end with a smoke script exercising exact-match reads, template-match reads,
unknown URIs, and legacy instance-only setups.
## Breaking Changes
No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for
setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds
the internal URI index.
Closes#98
## Motivation and Context
Resolves the `elicitation-sep1034-client-defaults` client conformance scenario
(SEP-1034, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034).
The Ruby HTTP client could not receive server-to-client requests at all:
it never opened the spec's standalone GET SSE listening stream, and SSE events that
were not responses were discarded. A server sending `elicitation/create` during a tools/call
(such as the conformance harness, which is built on the TypeScript SDK server)
either dropped the request silently or deadlocked the tool call waiting for an answer that
could never arrive.
This change adds three pieces:
### Server-to-client request dispatch
`MCP::Client::HTTP#on_server_request(method) { |params| ... }` registers a handler;
when a JSON-RPC request (a message with both `method` and `id`) arrives on any SSE stream,
the handler's return value is POSTed back to the server as the JSON-RPC `result`
(the server ACKs with 202 Accepted, per the transport spec).
Methods without a handler are answered with a `-32601` method-not-found error.
A handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code
(for example `-1` for a rejected sampling request, per the sampling spec),
mirroring the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return;
any other error is answered with a `-32603` internal error. In every case the server is not left waiting,
matching how the TypeScript and Python SDKs convert handler failures into JSON-RPC error responses.
### A standalone GET SSE listening stream
Registering a handler (once connected) opens the spec's listening stream on a background thread,
because servers deliver requests that are not tied to a client request on that stream -
the TypeScript SDK's `StreamableHTTPServerTransport` silently drops them when no GET stream is open.
Mirrors the TypeScript and Python SDK clients, which start listening right after the `initialize` handshake
without awaiting stream establishment. Reconnection semantics also follow both SDKs:
a gracefully closed stream is retried indefinitely after the server's `retry:` interval,
while consecutive connection failures (HTTP errors and idle read timeouts) stop the listener once
they reach the reconnection attempt cap, with a successful stream resetting the count;
a 405 stops immediately without retrying, since it is the spec's answer from a server that
offers no listening stream. The listening GET uses a 5-minute read timeout matching
the Python SDK's `sse_read_timeout` default. `close` terminates the thread.
### SEP-1034 defaults
`MCP::Client::Elicitation.apply_defaults` fills fields omitted from the elicitation content with
the `default` values declared in `requestedSchema` properties, never overwriting provided values
and skipping properties without a default. This follows the Python SDK, which applies defaults in
the elicitation callback; the TypeScript SDK's automatic `applyElicitationDefaults`
(gated on the `elicitation.form.applyDefaults` capability) is a possible follow-up.
`MCP::Client#on_elicitation` registers a handler for `elicitation/create` as a convenience.
The conformance client gains an `elicitation-sep1034-client-defaults` branch that connects with
`capabilities: { elicitation: {} }`, accepts the elicitation with defaults applied, and calls
the harness's `test_client_elicitation_defaults` tool; the scenario is removed from the expected failures baseline.
## How Has This Been Tested?
New unit tests covering: handler dispatch from a request's POST SSE stream with
the response body and 202 ACK verified, the `-32601` answer for unregistered methods,
the `-32603` answer for a raising handler, the custom-code answer when a handler raises
`ServerRequestError`, listener startup from both registration-after-connect and
connect-with-registered-handlers, listener shutdown after consecutive connection failures with
the count resetting after a successful stream and an immediate stop on 405,
and `apply_defaults` for all five SEP-1034 property types (string, integer, number, enum, boolean),
non-overwriting of provided values, omission of properties without defaults, and symbol-keyed schemas
## Breaking Changes
None. The listening stream and its background thread are created only when a server-request handler is registered;
clients that never call `on_server_request` / `on_elicitation` keep the existing single-request behavior.