## Motivation and Context
Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation,
both introduced against the frozen SEP text and overtaken by post-final spec changes:
1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it
optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only
the `protocolVersion` + `clientCapabilities` pair.
A conformant client configured not to identify itself was rejected.
2. A missing or mistyped envelope answered `-32600` Invalid Request.
The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer
with `-32602` naming the offending keys.
Fixing these also corrects the era classification to match the reference SDKs: a request claims
the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion`
(the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks),
and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming
the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without
the claim key (`progressToken`, trace context) is classified exactly as before.
Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on
a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK.
## How Has This Been Tested?
`test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone,
a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo`
(reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities`
and for mistyped required and optional fields.
`test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed
but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is
served with `server_context.client_info` reading `nil`, and a claim-less request on
a modern-locked session answers `-32602` naming the required keys.
The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths.
## Breaking Changes
None for conforming clients. Requests that were already rejected change error code
(`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key
with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests.
Envelopes without `clientInfo`, previously rejected, now succeed.
## Motivation and Context
The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange,
and two delivery requirements of that model were unimplemented:
- Notifications a handler emits while serving a modern request (progress, log messages)
had nowhere to go: the modern path answered with a single JSON object,
and the ephemeral per-request session degraded every notification to non-delivery.
The conformance requirement expects them as frames on the request's own response stream
(`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28).
- The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC,
was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement
that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`.
The changes wire both up:
- `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session;
`send_notification` routes into the sink ahead of the stateless guard.
A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response;
a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
- `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`,
and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level:
without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent,
the safe direction, matching the Python SDK's `allowed_log_levels`.
The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline
rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as
not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation`
and requires that every frame on the request's response stream is a notification or the final response,
and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel`
and requires that no `notifications/message` reaches the stream.
The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes;
`test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate,
which drops the notification for modern requests that did not authorize logging through
`io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds:
with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame
ahead of the final response rather than being dropped.
One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs:
delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved
and the conformance checks are satisfied, but long-running handlers get no real-time interleaving.
The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications
in proportion to client-supplied input cannot grow one request's memory without bound,
and past the cap the notify helpers report `false`, the same non-delivery degradation
they have on every other undeliverable path. Live streaming can follow without changing the wire shape.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover:
a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying
in place without notifications, log suppression without the envelope `logLevel`, log delivery with it,
the modern-era session ignoring the server-wide level while honoring a per-request one,
and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it.
Against the conformance fixture server, the `tools-call-with-progress` progress check passes
at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change),
the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and
`sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive
directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`,
`test_logging_tool` streams its `notifications/message` frame ahead of the result, and
with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame.
The `--requirements 2025-11-25` server leg passes 78/78, unchanged.
## Breaking Changes
None for stable protocol versions: legacy requests and their notification delivery are unchanged.
Within the modern lifecycle, notifications become deliverable where they were previously dropped,
and log messages without a per-request `logLevel` stop leaking through the server-wide level,
as the specification requires.
## Motivation and Context
SEP-2322 (merged for the 2026-07-28 spec release) makes `resultType` a REQUIRED member of every result
a 2026-07-28 server returns: "Servers implementing this protocol version MUST include this field",
with absence tolerated only when the result comes FROM an earlier protocol version.
The client-side vocabulary landed earlier (`MCP::ResultType`, treating an absent value as `"complete"`),
but every ordinary modern result the server returns is unstamped, which violates the MUST for `tools/list`,
`tools/call`, `ping`, and every other standard result on the modern wire.
The conformance suite's 2026-07-28 requirements fail each of those scenarios on wire-schema validation
(`CallToolResult: must have required property 'resultType'`).
The dispatch path now stamps `resultType: "complete"` centrally: any Hash result of a request carrying
the SEP-2575 `_meta` envelope that does not already carry a `resultType` gets the standard value merged in.
Results that already carry a discriminator keep it, notifications and cancellation-suppressed responses
are untouched, and legacy results stay unstamped because pre-2026 clients do not know the field.
`server/discover`, which is exempt from the `_meta` envelope (pre-version discovery),
stamps its `DiscoverResult` at its construction site instead.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: `tools/list`,
`ping`, and `tools/call` results carry `resultType: "complete"` on modern requests and no `resultType`
on legacy requests; `server/discover` carries the stamp. The conformance scenarios previously failing
on the missing property (`tools-call-simple-text`, `prompts-get-simple`, `completion-complete`, and
the other tools/prompts scenarios) pass at `--spec-version 2026-07-28` against the conformance fixture server.
## Breaking Changes
None. The stamp applies only to results of requests carrying the SEP-2575 modern `_meta` envelope;
results on stable protocol versions through 2025-11-25 are byte-for-byte unchanged.
## Motivation and Context
SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 MCP spec release)
replaces server-initiated JSON-RPC requests, which the stateless modern lifecycle (SEP-2575) forbids,
with multi round-trip results: a `tools/call`, `prompts/get`, or `resources/read` handler returns
`resultType: "input_required"` with `inputRequests` (server-assigned keys mapping to `elicitation/create`,
`sampling/createMessage`, or `roots/list` request shapes) and/or an opaque `requestState`,
and the client retries the ORIGINAL request with `inputResponses` under the same keys plus the echoed state.
The client-side recognition landed earlier (`MCP::ResultType`, `Client::InputRequiredError`);
this adds the server side, mirroring the Python SDK's low-level Server (python-sdk#2967/#2986)
and the TypeScript SDK's umbrella 2026-07-28 work.
- New `MCP::Server::InputRequiredResult` value object: construction validation
(at least one of the two fields, embedded methods restricted to the three shapes), `to_h` wire serialization,
and the capability mapping shared with the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`
(url-mode elicitation requires `elicitation.url`, form otherwise; sampling with `tools`/`toolChoice` requires
`sampling.tools`; `roots/list` requires `roots`), including the 2025 back-compat rule that a bare `elicitation: {}`
declaration implies form support.
- Handlers return the object through the existing paths: both branches of `call_tool_with_args` and
`call_prompt_template_with_args` pass it through instead of calling `.to_h`, `call_tool` skips
output schema validation and the structured-content fallback for it (output schema validation would otherwise
run against a `nil` `structuredContent`), and the `resources/read` dispatch skips the `contents` wrapping and
SEP-2549 cache-hint stamping.
- One central hook in the dispatch lambda gates and serializes the result, after the cancellation check
so cancelled requests stay suppressed: a legacy request (no SEP-2575 envelope) gets an internal error,
because pre-2026 clients treat an unknown `resultType` as a final result; embedded requests exceeding
the request's declared client capabilities get `-32021` with the full merged `requiredCapabilities` set.
- `MCP::ServerContext` gains `input_responses`, `request_state`, and the key-tolerant `input_response(key)` reader.
The retry fields are params-top-level siblings of `name`/`arguments`/`uri` (not `_meta`),
so only handlers that opt in to `server_context:` can participate, matching the envelope readers' access model.
The server holds no memory between rounds: handlers re-run from the start on every retry
(deterministic replay, as in the Python SDK).
The 2026-07-28 conformance requirements score 14 `input-required-result-*` scenarios and the capability
checks of `server-stateless`, and each drives a diagnostic fixture tool the server under test must define.
The fixture defines the tools those scenarios call, mirroring the suite's TypeScript reference fixture
(`examples/servers/typescript/everything-server.ts`) in tool names and `inputRequests` keys:
- `test_input_required_result_elicitation` / `_sampling` / `_list_roots`: one embedded request each,
answered on the retry round (also exercised by the missing-response, result-type, ignore-extra-params,
and validate-input scenarios)
- `test_input_required_result_request_state`: stamps an opaque `requestState` and completes only after
it round-trips
- `test_input_required_result_multiple_inputs`: all three embeddable methods in one result
- `test_input_required_result_multi_round`: two elicitation rounds with per-round `requestState`
- `test_input_required_result_capabilities`: builds `inputRequests` from the declared client capabilities,
so a sampling-only client is never asked to elicit
- prompt `test_input_required_result_prompt`: the non-tool MRTR flow over `prompts/get`
- `test_missing_capability`: guards on `require_client_capability!(:sampling)`, giving
the `server-stateless` capability checks a target that answers -32021 with HTTP 400
`test_input_required_result_tampered_state` is deliberately absent: rejecting a tampered `requestState` needs
the sealing machinery of the request-state security change, and its fixture tool lands there.
Sealing of the echoed `requestState` (it arrives as client-controlled input) and the client-side auto-fulfillment loop
follow in the next changes.
Refs #382.
## How Has This Been Tested?
New `test/mcp/server/input_required_result_test.rb` covers construction validation, key normalization and freezing,
the wire shape of `to_h`, the full capability mapping, missing-capability subtraction with symbol/string declarations,
and the implied-form back-compat rule.
New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: issuance wire shape for all three methods,
the retry leg exposing `input_responses`/`request_state`/ `input_response(key)` to the handler, the legacy-request internal error,
`-32021` with the merged `requiredCapabilities` data, output-schema validation bypass under `validate_tool_call_results: true`,
and `resources/read` results staying unwrapped without `ttlMs`/`cacheScope` stamping even when the server configures cache hints.
Against the fixture server at `--spec-version 2026-07-28`, all SEP-2322 checks of the 13 covered scenarios pass,
and `server-stateless` reports `sep-2575-server-rejects-undeclared-capability` and
`sep-2575-missing-capability-http-400` as SUCCESS. The `wire-schema-valid` failures on rounds that reach
a complete result come from the missing `resultType` stamp, which is the separate stamping change's job.
The `--requirements 2025-11-25` server leg still passes 78/78, and `bundle exec rake` is green.
## Breaking Changes
None. The serialization seams only branch on a return type that previously could not occur, all new keyword arguments default to `nil`,
and requests that carry no `inputResponses`/`requestState` behave exactly as before.
## Motivation and Context
Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic.
Server side, `Server#discover` reaches its spec-final shape:
- `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
legacy versions are negotiated via `initialize`, not selected from discovery. This also closes
an interop hazard of the interim response: auto-negotiating TS and Python clients treat
any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`,
so advertising only legacy versions made them fail outright.
- The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints
on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with
`ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise.
- The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp
instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002
(the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level
`serverInfo`, so the top-level placement was invisible to both). `Client#discover` and
the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built
against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`.
Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default:
- `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct
(`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`).
It works before `connect`; the stdio transport's connect guard exempts `server/discover`
and spawns the subprocess on demand.
- `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs
(the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:`
(the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover`
and falls back to the legacy handshake on any discovery failure, including a successful discovery
without a mutually supported modern version (rollout tolerance for servers that answer discovery
while only serving legacy versions). Custom transports whose `connect` does not declare `mode:`
keep receiving the historical legacy call shape, detected by keyword introspection;
a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords
stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError`
instead of silently downgrading.
- The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`,
instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried
the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make
the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version,
the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here
as `mode: :modern` with `protocol_version: "2026-07-28"`.
- An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe,
so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle.
`mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools
and when using server-initiated requests, which exist only on the legacy lifecycle.
- Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends
on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference:
`MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one),
`#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta`
`io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server
does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover.
- The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK)
when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately,
but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback
instead of blocking `connect` forever. Regular requests keep the unbounded default.
- In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto
every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names
from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and
keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize`
is never sent.
- The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake
and its scenario servers do not expect a preceding probe.
Resolves#389.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values);
the existing discover assertions across `server_test.rb` and both transport test files were updated to
the modern-only `supportedVersions`.
New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError`
on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`,
the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`,
`ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection,
and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence.
New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with
the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks,
and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`,
modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout
default applying only while probing, and the fallback when a server never answers the probe.
## Breaking Changes
`server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`);
this method has not shipped in a gem release yet, so no released behavior changes.
On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before
the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with
an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing
the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation
`protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable
across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
Tool handler exceptions and anything reaching JsonRpcHandler's blind
rescue had their #message echoed into the error data member, exposing
internals to untrusted clients (CWE-209). Capability errors keep their
client-facing message by re-raising through RequestHandlerError, the
one channel whose message is deliberately surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
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
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
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.
## 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
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
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
SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release)
makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of
per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both
the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and
the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration
and are purely additive:
- The `server/discover` method, which servers MUST implement: sessionless capability discovery
returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`.
The new `Server#discover` handler reuses the same data `init` already builds, but is state-free
and idempotent: it stores no client info, never marks a session initialized, and responds regardless
of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`).
`serverInfo` is returned unfiltered because discovery precedes version negotiation;
the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work.
On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement
and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with
a bare POST in both stateful and stateless modes.
- The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003`
and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet
(that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of
`URLElicitationRequiredError` is intentionally left untouched.
The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is
deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25,
where those methods are required.
Part of #389.
## How Has This Been Tested?
- `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities,
server info, and instructions; it responds before `initialize` and with empty capabilities;
it does not mark the session initialized (a subsequent `initialize` still succeeds);
`instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")`
now raises `MethodAlreadyDefinedError`.
- `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID
returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`.
- New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None at the wire level for existing clients. One narrow API consequence: servers that previously registered
their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because
the method is built in.
## Motivation and Context
SEP-2549 (modelcontextprotocol/modelcontextprotocol#2549, merged for the 2026-07-28 spec release) adds a `CacheableResult`
shape with `ttlMs` (freshness lifetime in milliseconds, max-age semantics, 0 = do not cache) and `cacheScope`
(`"public"` or `"private"`, whether shared intermediaries may cache) to `ListToolsResult`, `ListPromptsResult`,
`ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult`, complementing the `listChanged` notifications.
The field shape follows the Python SDK's implementation (python-sdk#2824, `ttlMs` + `cacheScope` with `ttlMs: 0` /
`cacheScope: "public"` defaults; the TypeScript SDK's earlier `ttl`-seconds prototype #2070 was superseded by this shape).
Because this SDK negotiates protocol versions up to 2025-11-25, where the fields do not exist, emission is opt-in:
when neither `ttl_ms` nor `cache_scope` is configured, responses are serialized exactly as before. The fields become REQUIRED
in 2026-07-28; a follow-up can auto-emit defaults once that version enters `SUPPORTED_STABLE_PROTOCOL_VERSIONS`.
- `MCP::Server.new` gains `ttl_ms:` and `cache_scope:` keywords with validating writers modeled on `page_size=`
(`ttl_ms` must be nil or a non-negative Integer; `cache_scope` must be nil, `"public"`, or `"private"`).
- A private `apply_cache_metadata` wraps the five emission points. When the user sets only one of the two values,
the other is filled with the spec defaults (`ttlMs: 0`, the only universally safe freshness value, and `cacheScope: "public"`,
the spec and Python default).
- A `resources_read_handler` may return a full `{ contents:, ttlMs:, cacheScope: }` hash to override the server-level hints per result;
the documented bare-contents return shape keeps working unchanged.
- The client's `ListToolsResult` / `ListPromptsResult` / `ListResourcesResult` / `ListResourceTemplatesResult` structs gain
`ttl_ms` and `cache_scope` members populated from the response fields.
Resolves#387.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb`:
- with `ttl_ms: 5000`, all four list results carry `ttlMs: 5000` and the filled `cacheScope: "public"` default
- with only `cache_scope: "private"`, `resources/read` carries `ttlMs: 0`
- with neither configured, list and read results omit both fields (wire-format regression)
- the hints appear alongside `nextCursor` when paginating
- a `resources_read_handler` returning `{ contents:, ttlMs: 60_000 }` overrides the server-level value while the missing `cacheScope` is filled
- invalid writer values (`-1`, `1.5`, `"internal"`) raise `ArgumentError`
New tests in `test/mcp/client_test.rb` assert all four list result structs expose `ttl_ms` / `cache_scope`,
and that both are nil when the server omits the fields.
## Breaking Changes
None. Emission is opt-in, the new constructor keywords default to nil, and the client structs gain additive members.
## Motivation and Context
A JSON-RPC notification carries no id and must not receive any response.
When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id,
the server replied with a successful `result: null`, which is neither a valid MCP result nor
a legal reply to a notification.
Such a message is now treated as a request for a method that has no request handler and rejected with
Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification
that omits the id continues to receive no response.
Fixes#434.
## How Has This Been Tested?
Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`,
and `notifications/progress`, a batch that mixes an id-bearing notification with other messages,
custom `notifications/*` methods, and the same case over the HTTP transport.
## Breaking Changes
None for spec-conformant clients, which never put an id on a notification.
The only behavioral change is that a malformed id-bearing notification now receives a Method not found error
instead of a spurious `result: null`.
Split the advisory YARD @deprecated annotations out of #406 so the
documentation can land ahead of the 2026-07-28 protocol release.
Per maintainer feedback, this drops the protocol-version-gated runtime
Kernel.warn behavior and the new 2026-07-28 supported protocol version,
leaving only the @deprecated notes for the Roots, Sampling, and Logging
APIs deprecated by SEP-2577.
Refs #390
Co-authored-by: 🦞 Qlaw <noreply@qlaw.quick.shopify.io>
## Motivation and Context
SEP-2106 (modelcontextprotocol/modelcontextprotocol#2106, merged for the 2026-07-28 spec release)
makes tool `inputSchema` and `outputSchema` conform to the full JSON Schema 2020-12 vocabulary:
an input schema keeps `type: "object"` at the root but may use any 2020-12 keyword below it;
an output schema may be ANY valid schema (object, array, primitive, or a root-level composition);
and `CallToolResult.structuredContent` widens from an object to any JSON value. The SEP also adds
resource bounds: `$ref` resolution is restricted (same-document only in the reference implementation) and
composition-heavy documents must be bounded to avoid excessive validation cost.
This follows the TypeScript SDK's reference implementation (typescript-sdk#2249; the Python SDK tracks
the same work in python-sdk#2792):
- `Tool::Schema` now validates against the JSON Schema 2020-12 metaschema rather than the draft-04 metaschema.
The draft-04 pin was a stopgap from when the SDK used the `json-schema` gem, which did not support 2020-12;
`json_schemer` does, so `$defs`/`$ref` and the rest of the 2020-12 vocabulary now resolve natively.
This matches the dialect the SDK already advertises in emitted schemas and the Python SDK's behavior,
whose `jsonschema.validate` selects the validator from the schema's `$schema`.
- `Tool::Schema` moves root-type defaulting into an overridable `apply_default_root_type!` hook.
`InputSchema` keeps the historical `type: "object"` default; `OutputSchema` now applies it only
when no root schema keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`) is present.
The previous unconditional default merged `type: "object"` into root combinators such as `{ oneOf: [...] }`,
producing a wrong schema, so that case is a bug fix.
- `Tool::Schema` enforces the TypeScript SDK's schema bounds at construction time: only same-document
`$ref`/`$dynamicRef`s (starting with `#`, so schema handling can never trigger network or file access),
`MAX_SCHEMA_DEPTH = 64` nesting levels, and `MAX_SUBSCHEMA_COUNT = 10_000` subschema objects,
all raising `ArgumentError` on violation.
- `Server#call_tool` mirrors non-object `structuredContent` into `content` as serialized JSON text
when the tool provided no content blocks, so pre-SEP clients that only read `content` still receive the data.
Object results and explicit content are untouched.
Resolves#377.
## How Has This Been Tested?
- `test/mcp/tool/output_schema_test.rb`: root-level `oneOf`, `$ref`+`$defs`, primitive, and `enum` schemas serialize
without an injected `type` and validate results correctly; the `properties`-only shorthand still serializes
with `type: "object"` (wire-format regression); explicit `type: "array"` keeps working.
- `test/mcp/tool/input_schema_test.rb`: an input schema using `$defs`, `$ref`, `oneOf`, `if`/`then`, and `allOf`
keeps its object root and round-trips all keywords; a draft-04-only boolean `exclusiveMinimum` is rejected
under the 2020-12 dialect while the numeric form is accepted.
- `test/mcp/tool/schema_test.rb`: depth and subschema-count bound violations raise `ArgumentError`;
non-same-document `$ref`s (remote URI, sibling file) are rejected while `#/$defs/...` is accepted.
The previous unbounded-depth caching test is replaced, since the depth bound now rejects such documents by design.
- `test/mcp/server_test.rb`: `tools/call` with array `structuredContent` and no content gains
the serialized TextContent fallback; explicit content is not overwritten; object `structuredContent` gets no fallback.
`bundle exec rake` (tests, RuboCop, and conformance baseline, including the `json-schema-2020-12` server scenario) passes.
## Breaking Changes
Three narrow behavior changes, all intentional per the SEP:
- Runtime validation now uses the JSON Schema 2020-12 metaschema instead of draft-04.
Schemas that rely on draft-04-only syntax are rejected at construction time. The practical case is the boolean
`exclusiveMinimum`/`exclusiveMaximum` form (deprecated since draft-06), which must now be the numeric form;
the Python SDK rejects it the same way. Other draft-04 spellings (`definitions`, `id`, `dependencies`) still validate,
since 2020-12 tolerates unknown keywords.
- Schemas that exceed the new resource bounds (nesting deeper than 64, more than 10,000 subschema objects) or
use a non-same-document `$ref`/`$dynamicRef` now raise `ArgumentError` at construction time.
Previously such documents were accepted (external references were already never fetched, only ignored).
- An `OutputSchema` whose root declares a schema keyword other than `type` (e.g. `oneOf`) no longer has `type: "object"`
merged into it. The old output was an invalid hybrid schema, so no conforming consumer could have relied on it.
## Motivation and Context
The transports parse incoming JSON with `symbolize_names: true`, so a tool receives every key,
at every nesting level, as a Ruby symbol. The dispatch in `call_tool_with_args` only runs
a shallow `transform_keys(&:to_sym)`, which is effectively a no-op given the deep parse upstream.
Nothing in the SDK exercised that delivered shape: the README and examples take flat or primitive arguments,
and the tool tests invoke `call` with Ruby keyword arguments, so none go through the JSON parse to dispatch path.
A tool that reads a nested object with string keys (`payload["subject"]`) therefore passes its tests and then
returns nil against a real client.
Ruby keyword arguments bind only on symbol keys, so the top-level keys must be symbols for
the ergonomic `def call(message:, ...)` API to work. Aligning fully with the JSON wire shape that the spec and
the Python and TypeScript SDKs use (string keys) is not possible without a breaking change to that API.
This change therefore documents the existing contract rather than altering behavior.
Closes#412
## How Has This Been Tested?
New regression tests in `test/mcp/server_test.rb`:
- `tools/call` through the full `handle_json` path delivers nested object arguments with symbol keys at every level,
and string-key access on a nested value returns nil.
- A tool called under the JSON-round-tripped argument shape receives symbol keys, mirroring what a transport hands it at runtime.
## Breaking Changes
None. This is a documentation and test change with no behavior change.
The `lib/mcp/server.rb` edit is a clarifying comment on the existing key handling.
## Additional context
Making nested objects tolerant of both string and symbol keys (indifferent access) is a possible future improvement
that would remove the footgun and move closer to the other SDKs. It is intentionally left out of scope here and
can be designed separately, since it is a behavior and dependency decision rather than a documentation fix.
## Motivation and Context
SEP-2133 (modelcontextprotocol/modelcontextprotocol#2133, merged for
the 2026-07-28 spec release) establishes the extensions framework for MCP:
both `ClientCapabilities` and `ServerCapabilities` gain an optional `extensions`
member keyed by reverse-DNS extension identifiers
(e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`), with arbitrary
extension-defined configuration objects as values and an empty object meaning
"supported with no settings". Official extensions such as MCP Apps (SEP-1865)
and the Tasks extension (SEP-2663) are layered on this field.
This mirrors the shape the TypeScript SDK merged (typescript-sdk#1630):
plain declaration and passthrough, no registrar API and no key-format validation
(the naming convention is documented instead).
- `MCP::Server::Capabilities` gains `support_extensions` (repeated calls merge),
accepts an `:extensions` key in its constructor hash, and includes `extensions`
in `to_h`. The class was previously dead code: it was never required and
`Server.new` only took plain hashes. It is now required by `lib/mcp/server.rb`,
and `Server.new(capabilities:)` accepts either a plain Hash (as before) or
a `Capabilities` instance.
- Server-declared extensions appear in the `initialize` result;
extensions the client declares during `initialize` were already stored verbatim and
are readable via `Server#client_capabilities[:extensions]` and
`ServerSession#client_capabilities[:extensions]` (now covered by tests).
- `MCP::Client#connect(capabilities:)` already passes capabilities through verbatim;
its documentation now covers the `extensions` member.
Resolves#378.
## How Has This Been Tested?
- New `test/mcp/server/capabilities_test.rb` covers the previously untested
`Capabilities` class: empty default, constructor round-trip, extensions via
constructor and `support_extensions`, merge semantics, nil tolerance,
and omission from `to_h` when never declared.
- New tests in `test/mcp/server_test.rb` assert that the `initialize` result
carries declared extensions (from both a plain hash and a `Capabilities` instance)
and that client-declared extensions are readable via `Server#client_capabilities`
and `ServerSession#client_capabilities`.
## Breaking Changes
None. `extensions` is additive and appears on the wire only when declared;
servers constructed with plain capability hashes behave exactly as before.
## Motivation and Context
SEP-2164 (modelcontextprotocol/modelcontextprotocol#2164, merged for the 2026-07-28 spec release)
standardizes the resource-not-found error on the JSON-RPC Invalid Params code (-32602),
dropping the legacy -32002 code, and both reference SDK implementations attach the requested URI
as structured error data (typescript-sdk#2267 throws `InvalidParams` with `data: { uri }`;
python-sdk#2344 adds a `ResourceNotFoundError` that maps to `code=-32602, data={"uri": ...}`).
The Ruby server already used -32602 (via `error_type: :invalid_params`) for its built-in not-found path
(`completion/complete` with an unknown `ref/resource` URI), but the response carried no structured data
and the generic "Invalid params" message, and there was no public error class for `resources_read_handler`
blocks to raise. The Ruby client never special-cased -32002, so nothing needed removal there.
- New public `MCP::Server::ResourceNotFoundError` (modeled on the `URLElicitationRequiredError` precedent):
code -32602, message `"Resource not found: <uri>"`, and `data: { uri: <uri> }`.
Custom `resources_read_handler` blocks raise it for unknown URIs; the README documents the pattern.
- The `completion/complete` `ref/resource` not-found path now raises this class,
so its error response gains `data: { uri: }` and a descriptive message instead of
the generic "Invalid params" text.
- The default `resources/read` behavior (returning empty contents for unknown URIs
when no handler is registered) is intentionally unchanged; raising there would
alter existing wire behavior.
Resolves#379.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb`:
- `resources/read` with a handler raising `ResourceNotFoundError` responds
with code -32602, message `"Resource not found: file:///missing.txt"`,
and `data: { uri: "file:///missing.txt" }`
- `completion/complete` with an unknown `ref/resource` URI carries the same code,
message, and structured data
New test in `test/mcp/client_test.rb` asserts `Client#read_resource` surfaces
the -32602 code and the `data` hash unmodified through `Client::ServerError`
(regression: no special-casing swallows it).
The pre-existing completion error-code tests pass unchanged.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None at the JSON-RPC code level (-32602 before and after).
The `completion/complete` resource-not-found error response changes shape within
the error object: `message` becomes `"Resource not found: <uri>"` instead of
`"Invalid params"`, and `data` becomes `{ uri: <uri> }` instead of the message string.
Both members are advisory per JSON-RPC 2.0.
## Motivation and Context
The MCP specification defines `ping` as a bidirectional utility: either party may issue it.
https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
The previous PR added `MCP::Client#ping`. This PR adds the symmetric server-side send capability,
`ServerSession#ping`.
This aligns the Ruby SDK with the Python SDK (`ServerSession.send_ping`) and
the TypeScript SDK (`Server.ping()`), both of which expose a server-initiated ping.
In practice this enables a Ruby MCP server to health-check non-Ruby clients
(e.g., Claude Desktop, the Python or TypeScript SDK client) during long-running tool operations.
Scope is intentionally server-only: this PR does not add client-side inbound request dispatch,
which is a larger refactor outside the ping utility. A Ruby `MCP::Client` therefore still cannot receive
a server-initiated ping; that gap is tracked as a separate follow-up.
## How Has This Been Tested?
New file `test/mcp/server_session_ping_test.rb` covers:
- success (empty `result` returned)
- `related_request_id` propagated through the transport when a session ID is set
- `ValidationError` when the transport returns `nil`
- `ValidationError` when the transport returns a non-Hash `result`
- transport-level errors propagate unchanged
- ping succeeds without any client capability declaration (per spec, ping has no capability gate)
`test/mcp/server_context_test.rb` adds two tests for `ServerContext#ping` delegation
(happy path and `NoMethodError` when the session lacks `#ping`).
The existing server-side `ping` handler tests continue to pass unchanged.
## Breaking Changes
None. `MCP::ServerContext#ping` and `MCP::Server::ValidationError` are purely additive.
## Motivation and Context
The Ruby SDK accepted duplicate `initialize` requests after a session was already initialized.
On stdio, a second `initialize` silently overwrote the per-session `clientInfo` and `clientCapabilities`
(including with an older `protocolVersion`). On Streamable HTTP, every `initialize` minted a fresh
`Mcp-Session-Id` and `ServerSession`, abandoning the originally negotiated session.
MCP specification (`2025-06-18` / `2025-11-25` lifecycle) states that the initialization phase
MUST be the first interaction between client and server;
re-initialization on an established session is not part of the defined lifecycle.
TypeScript SDK rejects duplicate `initialize` on a live session with HTTP 400 + JSON-RPC `-32600`
("Invalid Request: Server already initialized"), and Python SDK does not mint a new session
on duplicate `initialize`. The Ruby SDK was the outlier; this change aligns it with the TypeScript SDK.
- `ServerSession` tracks an `@initialized` flag, exposed via `initialized?` and
set by `mark_initialized!` after a successful `initialize` response.
- `Server#init` raises `RequestHandlerError(error_type: :invalid_request)` when
the session is already initialized, which the existing error mapping converts
to JSON-RPC `-32600 Invalid Request`.
- `StreamableHTTPTransport#handle_post` short-circuits at the transport layer:
duplicate `initialize` against a live session returns HTTP 400 + JSON-RPC
`-32600`; a stale or expired `Mcp-Session-Id` returns 404 (evicting the expired entry
instead of misreporting it as a duplicate).
- `handle_initialization` evicts the registered session and omits the `Mcp-Session-Id`
header when the first `initialize` fails before `mark_initialized!` is reached,
so retries do not collide with an orphaned ID.
- Non-Hash JSON-RPC POST bodies (e.g. batched arrays, which are not supported in `2025-11-25`)
are explicitly rejected with HTTP 400 + JSON-RPC `-32600` rather than falling through to
an unparseable Rack response.
## How Has This Been Tested?
- Server tests: a second `initialize` on the same `ServerSession` returns
`code: -32600` and the original `clientInfo` is preserved.
- Streamable HTTP tests: duplicate `initialize` with a live `Mcp-Session-Id`
returns HTTP 400 + `-32600` and the original session remains usable for
subsequent `ping`; stale `Mcp-Session-Id` returns 404; an idle-expired
session is evicted on duplicate `initialize` and returns 404; a failed
`initialize` (invalid `jsonrpc` envelope) does not leak `Mcp-Session-Id` and
leaves `@sessions` empty; an array body is rejected with HTTP 400 + `-32600`.
- Stdio tests: a second `initialize` on the same stdio session returns
`code: -32600` and the original `clientInfo` is preserved.
## Breaking Changes
Clients that previously sent `initialize` more than once on the same session
now receive a JSON-RPC error with `code: -32600` for the second and later requests
instead of silently overwriting session state (stdio) or being re-issued
a new `Mcp-Session-Id` (Streamable HTTP). Clients that follow the MCP specification
(single `initialize` per session) are unaffected.
Additionally, non-Hash JSON-RPC POST bodies on Streamable HTTP now return
HTTP 400 + JSON-RPC `-32600` rather than falling through to a broken Rack response.
The previous behavior produced an unparseable response, so this is unlikely to
affect any working client.
Closes#349.
## Motivation and Context
The MCP specification defines `notifications/cancelled` so either party can stop
a previously-issued in-flight request. The Ruby SDK declared the method constant
but had no receive logic, no in-flight tracking, and no way for handlers to participate
in cancellation; cancellation notifications fell through to the unsupported-method path.
This PR implements the **server-side** half of the spec. The server stops processing
the targeted request cooperatively and suppresses its JSON-RPC response, matching
the Python SDK's anyio `CancelScope` and the TypeScript SDK's `RequestHandlerExtra.signal`.
Cancellation is observable by every user-overridable request handler:
- `Tool.call` (tools/call)
- prompt templates (prompts/get)
- blocks registered via `resources_read_handler` / `completion_handler` /
`resources_subscribe_handler` / `resources_unsubscribe_handler` / `define_custom_method`
Each handler opts in by declaring a `server_context:` keyword and polls `server_context.cancelled?`
or calls `server_context.raise_if_cancelled!` (raising `MCP::CancelledError`) inside long-running work.
Handlers that keep their existing `|params|` (or `|args|`) signature continue to work unchanged.
On `StreamableHTTPTransport`, cancelling a parent `tools/call` automatically cancels n
ested server-to-client requests (`sampling/createMessage`, `elicitation/create`);
the nested `send_request` raises `MCP::CancelledError` and a cancel notification is routed to
the peer on the parent's POST response stream. `StdioTransport` is single-threaded and blocks on `$stdin.gets`,
so it deliberately does not propagate nested cancellation.
Tools running on stdio still observe cancellation between calls via `server_context.cancelled?`.
The design is cooperative-only (no `Thread#raise`) because preemptive cancellation is unsafe
across Rack adapters and arbitrary handler code. The `initialize` request is never cancellable,
satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications
are silently ignored per the spec.
`MCP::Client#cancel` (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
## How Has This Been Tested?
`test/mcp/cancellation_test.rb` covers the `MCP::Cancellation` token contract.
`test/mcp/server_cancellation_test.rb` covers end-to-end cancellation:
- A handler spins on `cancelled?` in a background thread; the test sends `notifications/cancelled`
and asserts the handler observed cancellation and the JSON-RPC response was suppressed.
Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method)
has its own regression test.
- `initialize` is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored.
- The `reason` propagates into the `cancellation_reason` instrumentation field.
- Custom transports implementing only the abstract `(method, params = nil)` contract keep working;
the new kwargs (`session_id:`, `related_request_id:`, `parent_cancellation:`, `server_session:`)
are silently dropped when the transport's signature does not declare them.
`StreamableHTTPTransport` tests cover nested cancellation, hook deregistration after normal completion
(so a late parent cancel does not emit a stray `notifications/cancelled`), and the first-writer-wins race
when a real response and a cancel arrive concurrently.
## Breaking Change
None. `MCP::ServerContext#initialize` gains an optional `cancellation:` keyword (defaults to `nil`),
and `StreamableHTTPTransport#send_request` / `#send_notification` gain optional cancellation kwargs.
The abstract `Transport` base class and `StdioTransport` keep their existing `(method, params = nil)` signatures,
and custom transports following those signatures continue to work. `StreamableHTTPTransport` now dispatches
client-originated notifications through `ServerSession#handle_json` before returning 202;
previously these were accepted without dispatch, but the existing handlers for `notifications/initialized`
and `notifications/progress` were already no-ops, so no user-visible effect is expected.
Adding `server_context:` to a handler block is strictly opt-in.
## Motivation and Context
MCP 2025-11-25 (SEP-1303) clarifies that input validation errors for `tools/call`
should be returned as Tool Execution Errors (`{ content: [...], isError: true }`)
rather than as JSON-RPC `-32602` protocol errors, so models can observe
the validation message and self-correct on a follow-up call.
https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1303
This is a clarification rather than a brand-new requirement: the 2024-11-05, 2025-03-26,
and 2025-06-18 specifications all defined two error categories with overlapping language
("Invalid arguments" listed under Protocol Errors *and* "Invalid input data" listed under
Tool Execution Errors), leaving the routing of JSON-Schema validation failures ambiguous.
The Ruby SDK had selected the "Protocol Errors / Invalid arguments" interpretation,
while the TypeScript SDK (`packages/server/src/server/mcp.ts`, which wraps
`validateToolInput` errors via `createToolError`) and Python SDK's FastMCP
(which routes through the generic `is_error=True` path) had selected the other.
SEP-1303 in 2025-11-25 disambiguates this by replacing the bullets with
"Malformed requests (requests that fail to satisfy CallToolRequest schema)"
under Protocol Errors and "Input validation errors (e.g., date in wrong format,
value out of range)" under Tool Execution Errors, and explicitly states that the latter
"contain actionable feedback that language models can use to self-correct".
`tool_not_found` continues to be returned as a JSON-RPC `-32602` protocol error since
the spec change only covers input validation.
## How Has This Been Tested?
Updated existing tests that previously asserted `-32602` and "Invalid arguments" /
"Missing required arguments" in the JSON-RPC error data to instead
assert `result[:isError] == true` with the same text in the `content` block.
Added new regression tests covering:
- Instrumentation still records `:missing_required_arguments` and
`:invalid_schema` on the new non-raising path
- Nested schema validation failure (deep arrays of objects with
required fields) returns a tool execution error rather than a
protocol error
- `tool_not_found` continues to return JSON-RPC `-32602` (regression
guard against accidentally widening the change)
`bundle exec rake test` and `bundle exec rake rubocop` both pass.
## Breaking Changes
Yes. Clients that detect tool argument validation errors via `error.code == -32602`
will need to switch to inspecting `result.isError == true` and reading `result.content[].text`.
Note that the Ruby SDK's previous behavior was a defensible reading of the 2024-11-05 /
2025-03-26 / 2025-06-18 spec wording. The 2025-11-25 disambiguation is what makes
the previous behavior non-conforming; the TypeScript and Python SDKs already shipped
the new behavior.
## Motivation and Context
The MCP specification defines `resources/subscribe`, `resources/unsubscribe`,
and `notifications/resources/updated` for clients to monitor resource changes:
https://modelcontextprotocol.io/specification/2025-03-26/server/resources#subscriptions
The Ruby SDK had stub (no-op) handlers but provided no way for server developers to
customize subscription behavior or send update notifications.
Following the Python SDK approach, the SDK does not track subscription state internally.
Server developers register handler blocks and manage their own subscription state,
allowing flexibility for different subscription semantics
(per-session tracking, persistence, debouncing, etc.).
Three methods are added:
- `Server#resources_subscribe_handler`: registers a handler for
`resources/subscribe` requests
- `Server#resources_unsubscribe_handler`: registers a handler for
`resources/unsubscribe` requests
- `ServerSession#notify_resources_updated`: sends a
`notifications/resources/updated` notification to the subscribing client
`ServerContext#notify_resources_updated` is also added so that tool handlers
can send the notification scoped to the originating session.
## How Has This Been Tested?
All tests pass (`rake test`), RuboCop is clean. New tests cover custom handler registration
for `resources/subscribe` and `resources/unsubscribe`, session-scoped `notify_resources_updated` notifications,
error handling, and `ServerContext` delegation.
## Breaking Changes
None.
## Motivation and Context
The MCP specification defines cursor-based pagination for list operations that
may return large result sets:
https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination
Pagination allows servers to yield results in smaller chunks rather than all at once,
which is especially important when connecting to external services over the internet.
The Ruby SDK previously returned complete arrays for all list endpoints
(`tools/list`, `resources/list`, `prompts/list`, `resources/templates/list`)
without pagination support. This adds cursor-based pagination.
### Server-side
A new `MCP::Server::Pagination` module is introduced (mixed into `MCP::Server`)
that provides the `paginate` helper for slicing a collection by cursor and
a `cursor_from` helper that extracts the cursor from the request params
while rejecting non-Hash inputs with `-32602 Invalid params` per the specification.
`MCP::Server.new` accepts a new `page_size:` keyword argument.
When `nil` (the default), all items are returned in a single response,
preserving the existing behavior. When set to a positive integer,
list responses include a `nextCursor` field if more pages are available.
`Server#page_size=` is a validating setter that rejects anything other than
`nil` or a positive `Integer`, raising `ArgumentError` on invalid inputs.
The constructor routes through the setter, so both `Server.new(page_size: 0)`
and `server.page_size = -1` raise.
Cursors are string tokens carrying a zero-based offset, treated as opaque by
clients. Cursor inputs are validated to be strings per the MCP spec;
invalid cursors (non-string, non-numeric, negative, or out-of-range) raise
`RequestHandlerError` with error code `-32602 (Invalid params)` per the spec.
### Client-side: two complementary APIs
The SDK exposes two API shapes for listing, each serving a different use case:
1. **Single-page with cursor** (`client.list_tools(cursor:)`,
`list_resources`, `list_resource_templates`, `list_prompts`): each call issues
one JSON-RPC request and returns a result object (`MCP::Client::ListToolsResult` etc.)
carrying the page items and an optional `next_cursor`.
These methods match the single-page-with-cursor convention used by the Python SDK
(`session.list_tools(params=PaginatedRequestParams(cursor=...))`)
and the TypeScript SDK (`client.listTools({ cursor })`). The method name and
the `cursor` parameter name are identical across the three SDKs,
so pagination code translates directly between languages.
2. **Whole-collection** (`client.tools`, `client.resources`, `client.resource_templates`,
`client.prompts`): auto-iterates through all pages and returns a plain array of items,
guaranteeing the full collection regardless of the server's `page_size` setting.
The auto-pagination loop tracks previously seen cursors in a set and exits if the server
revisits any of them, guarding against both immediate repeats and multi-cursor cycles
(e.g. `A -> B -> A`). This mirrors Rust SDK's `list_all_*` convenience methods,
though the Ruby names are preserved from the pre-pagination 0.13.0 API for backward compatibility.
Future rename to `list_all_*` is left as a TODO in the source.
Result classes are `Struct`s named to mirror Python SDK's `ListToolsResult` /
`ListPromptsResult` / etc. to align naming with other SDKs. Each struct exposes
its page items (e.g. `result.tools`), an optional `next_cursor` for continuation,
and an optional `meta` field mirroring the MCP `Result` type's `_meta` response field
so servers that decorate list responses do not lose metadata through the client.
### Ruby-specific ergonomics
`Server.new(page_size:)` and the `Pagination` module are Ruby-specific.
The Python and TypeScript SDKs do not expose a built-in `page_size` helper;
developers there re-implement cursor slicing inside each handler.
In Ruby, setting `page_size:` on the server is enough for the SDK to handle
the slicing across the four built-in list endpoints.
### README
A Pagination section documents both the server-side `page_size:` option and
the client-side iteration patterns, including an explicit note that each `list_*` call
is a single JSON-RPC round trip whose response size depends on the server's `page_size`,
and that `client.tools` / `.resources` / `.resource_templates` / `.prompts` return
the complete collection when needed.
## How Has This Been Tested?
- Added tests for the `Pagination` module (`test/mcp/server/pagination_test.rb`),
server-side pagination (`test/mcp/server_test.rb`), and client-side pagination
(`test/mcp/client_test.rb`).
- Added and existing tests pass. `rake rubocop` is clean and `rake conformance` passes.
## Breaking Changes
None. This is a new feature addition. Existing code continues to work unchanged:
`page_size` defaults to `nil` on the server (returns all items, no `nextCursor`),
and the client's existing `tools` / `resources` / `resource_templates` / `prompts` methods
continue to return complete arrays by transparently iterating through pages.
## Motivation and Context
MCP spec (2025-11-25) defines Roots as a mechanism for clients to expose filesystem boundaries
(directories and files) to servers:
https://modelcontextprotocol.io/specification/2025-11-25/client/roots
This enables servers to understand which parts of the filesystem they can operate on.
This adds support for the two roots protocol interactions:
- `roots/list` -- server-to-client request to retrieve the client's filesystem roots
- `notifications/roots/list_changed` -- client-to-server notification when roots change
`roots/list` is a one-to-one request between a server and a specific client,
so `list_roots` is exposed only at the session level
(`ServerSession#list_roots`), matching the Python SDK approach and aligning with PR #311 for sampling.
`Server#on_roots_list_changed` registers a callback for the client notification,
which is server-wide and does not require session scoping.
Also fixes `notifications/roots/list_changed` capability checking: as a client-originated notification,
it should not require server-side capability declaration.
## How Has This Been Tested?
New test file `test/mcp/server_roots_test.rb` with 8 test cases covering session-scoped `list_roots`,
capability validation and fallback, `on_roots_list_changed` callback registration, and client capability storage.
Updated `test/mcp/methods_test.rb` to reflect the corrected capability checks. All tests pass (`rake test`),
RuboCop is clean.
## Breaking Changes
None.
## Motivation and Context
The MCP specification (2025-11-25) defines elicitation as a server-to-client
request mechanism that allows servers to request user input during tool execution:
https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
This aligns with the Python SDK, which also exposes separate methods for form and URL mode elicitation.
Three methods are added following the Python SDK's API design:
- `create_form_elicitation`: collects structured data via JSON Schema
- `create_url_elicitation`: directs users to external URLs for sensitive
interactions (OAuth, credentials, etc.)
- `notify_elicitation_complete`: notifies the client that a URL mode
interaction has completed
`URLElicitationRequiredError` (error code `-32042`) is defined as a subclass of `RequestHandlerError`
with its own `error_code` and `error_data` attributes. `JsonRpcHandler#handle_request_error` is
extended to check for these attributes so that MCP-specific error codes can be propagated without
embedding MCP knowledge in the generic JSON-RPC layer.
## How Has This Been Tested?
- Unit tests for `ServerSession` form/URL mode, capability checks, nil
capabilities, notification, and `URLElicitationRequiredError`
- `ServerContext` delegation tests for all three elicitation methods
- `JsonRpcHandler` tests for custom error code and fallback behavior
- `Methods` capability test for `NOTIFICATIONS_ELICITATION_COMPLETE`
- Conformance tests pass (40/40), including `tools-call-elicitation`,
`elicitation-sep1034-defaults`, and `elicitation-sep1330-enums`
- All tests pass (`rake test`), RuboCop is clean
- Verified form mode elicitation end-to-end with a sample MCP server
running in Claude Code
## Breaking Changes
None. This is a new feature addition.
## Motivation and Context
Sampling is a one-to-one request/response interaction between a server and a specific client.
`Server#create_sampling_message` does not scope requests to a specific session, which could
lead to unintended broadcasts on multi-client transports. `ServerSession#create_sampling_message`
should be used instead, as it routes the request to the correct client session.
This aligns with the Python SDK, which also exposes sampling only at the session level.
## How Has This Been Tested?
Existing tests updated to call `ServerSession#create_sampling_message` instead of
`Server#create_sampling_message`. All tests pass (`rake test`), RuboCop is clean,
and conformance tests pass.
## Breaking Changes
`Server#create_sampling_message` is removed without a deprecation warning.
The sampling feature was released recently and the method was not intended
to be the primary API for this feature.
## Motivation and Context
`instrumentation_callback` fires after request execution, making it impossible to wrap
requests with Application Performance Monitoring (APM) spans (e.g. Datadog tracing).
This adds `around_request`, which wraps request handling and allows executing code
before and after each request.
For example, to wrap requests with a Datadog trace:
```ruby
MCP.configure do |config|
config.around_request = ->(data, &request_handler) {
Datadog::Tracing.trace("mcp.#{data[:method]}") do
request_handler.call
end
}
end
```
`instrumentation_callback` is soft-deprecated in favor of `around_request`.
## How Has This Been Tested?
Added tests for `around_request` in configuration, instrumentation,
and server test files. All existing tests continue to pass.
## Breaking Changes
`around_request` itself is optional and defaults to a pass-through,
and `instrumentation_callback` continues to work as before.
However, `instrument_call` now accepts a `server_context:` keyword and its block is invoked
without arguments (previously `yield block` passed the block Proc as an argument).
This affects anyone mixing `MCP::Instrumentation` into their own classes,
though the public `Server` API is unchanged.
Resolves#302.
## Motivation and Context
The MCP Streamable HTTP specification defines that servers can return POST responses
as SSE streams and send server-to-client JSON-RPC requests and notifications through them:
> If the input is a JSON-RPC request, the server MUST either return
> `Content-Type: text/event-stream`, to initiate an SSE stream, or
> `Content-Type: application/json`, to return one JSON object.
If the server initiates an SSE stream:
> The server MAY send JSON-RPC requests and notifications before
> sending the JSON-RPC response. These messages SHOULD relate to the
> originating client request.
See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
Previously, when a GET SSE stream was connected, the Ruby SDK sent POST response bodies through
the GET stream and returned a 202 HTTP status for the POST itself. When no GET SSE stream was
connected, the SDK correctly returned `application/json` responses. The 202 behavior with GET SSE
was non-compliant with the specification, which requires POST requests to return `text/event-stream`
or `application/json`. Additionally, since GET SSE is optional per the specification, clients that
did not establish a GET SSE connection could not receive server-to-client messages (e.g.,
`sampling/createMessage`, log notifications) during request processing.
With this change, `handle_regular_request` always returns the POST response as an SSE stream for stateful sessions.
Each POST response stream is stored in `session[:post_request_streams]` keyed by `related_request_id` (the JSON-RPC request ID),
enabling correct routing when multiple POST requests are processed concurrently on the same session.
Server-to-client messages with a `related_request_id` are routed to the originating POST response stream only;
when `related_request_id` is nil, the GET SSE stream is used.
The TypeScript and Python SDKs already support this pattern.
### Internal Changes
`JsonRpcHandler.handle` and `JsonRpcHandler.handle_json` now pass both `method_name` and `request_id` to the method finder block.
This allows `Server#handle_request` to receive `related_request_id` directly from the protocol layer.
Without this, `related_request_id` would need to be relayed as a keyword argument through `Server#handle_json`,
`ServerSession#handle_json`, and `dispatch_handle_json`, unnecessarily exposing it on public method signatures.
This follows the same design as the TypeScript and Python SDKs, where the protocol layer extracts the request ID
and propagates it to the handler context.
## How Has This Been Tested?
Added tests for POST response stream:
- `send_request` via POST response stream (sampling with and without GET SSE)
- `send_notification` via POST response stream (logging without GET SSE)
- `progress` notification via POST response stream (without GET SSE)
- POST request returns SSE response even with GET SSE connected
- Session-scoped notifications (log, progress) are sent to POST
response stream, not GET SSE stream
- `active_stream` does not fall back to GET SSE when
`related_request_id` is given but request stream is missing
Updated existing tests to handle SSE response format where applicable.
## Breaking Changes (spec compliance fix)
POST responses for JSON-RPC requests in stateful sessions now return `Content-Type: text/event-stream`
instead of being sent through the GET SSE stream with a 202 HTTP status. Clients that relied on receiving responses
via the GET SSE stream will need to read the POST response body instead. This is a spec compliance fix:
the MCP specification requires POST requests to return `text/event-stream` or `application/json`,
not 202 with the response on a separate GET stream.
## Motivation and Context
The MCP specification defines `sampling/createMessage` to request LLM completions from the client.
The Ruby SDK did not yet support server-to-client requests,
so the server had no way to ask the client to sample an LLM.
This caused the `tools-call-sampling` conformance test to fail.
This adds `Server#create_sampling_message` to send a `sampling/createMessage` request
to the client via the transport layer. The method validates that the client declared
the `sampling` capability during initialization, and optionally checks the
`sampling.tools` capability when tools or tool_choice parameters are provided.
On the transport side, `Transport#send_request` is introduced as an abstract method,
with implementations in both `StdioTransport` and `StreamableHTTPTransport`.
Each transport assigns a unique request ID, sends the JSON-RPC request to the client,
and blocks until the client responds.
Client capabilities are stored per session via `ServerSession`, so validation is scoped
to the originating client. `ServerSession#create_sampling_message` uses
`build_sampling_params` for capability validation and sends the request directly through
the transport with the session ID, following the same pattern as `notify_progress`.
`Server#create_sampling_message` is the public API for single-client transports
(e.g., StdioTransport).
For multi-client transports (e.g., StreamableHTTPTransport),
`ServerSession#create_sampling_message` must be used to route requests to the correct client.
Tools using `server_context.create_sampling_message` automatically route to the correct session
regardless of transport.
`StreamableHTTPTransport#send_request` requires `session_id` to prevent broadcasting sampling requests
to all connected clients. Missing or invalid session IDs raise explicit errors instead of silently failing.
The `include_context` parameter is soft-deprecated in the MCP specification but is included
for compatibility with existing clients that declare the `sampling.context` capability.
Python and TypeScript SDKs also retain this parameter.
Ref: https://modelcontextprotocol.io/specification/latest/server/utilities/sampling
## How Has This Been Tested?
Added comprehensive tests in `test/mcp/server_sampling_test.rb` covering required and optional parameters,
capability validation, error handling, nil-param omission, per-session capability isolation,
and HTTP init capability scoping.
Added transport tests in `test/mcp/server/transports/stdio_transport_test.rb` and
`test/mcp/server/transports/streamable_http_transport_test.rb` for `send_request` round-trip behavior,
error responses, stateless mode rejection, missing session_id, invalid session_id, and no-active-stream error.
Conformance: `tools-call-sampling` is removed from expected failures. The conformance server's `TestSampling`
tool now calls `server_context.create_sampling_message` instead of returning a stub error.
This aligns the Ruby SDK with the MCP specification and other SDK implementations.
## Breaking Changes
None for end users.
`Transport#send_request` is a new abstract method. `ServerSession#create_sampling_message` is a new method.
## Motivation and Context
The MCP specification defines `completion/complete` for providing autocompletion
suggestions for prompt arguments and resource template URIs.
The Ruby SDK previously had only a no-op handler that returned empty results,
with no way for users to define custom completion logic.
This aligns the Ruby SDK with the Python and TypeScript SDKs,
both of which support user-defined completion handlers.
## How Has This Been Tested?
Server tests cover: default handler, custom handler for `ref/prompt` and `ref/resource`,
context argument passing, 100-item truncation, and error responses for nonexistent prompts,
nonexistent resource templates, and invalid ref types.
Client tests cover: request structure, context parameter inclusion,
and fallback when result is missing.
## Breaking Changes
None. The existing default no-op handler behavior is preserved.
The `completion_handler` method is purely additive. The only behavioral change
is that `completion/complete` requests now validate that the referenced prompt
or resource template exists before calling the handler,
returning an `invalid_params` error for unknown references.
Per the MCP spec, invalid arguments and server errors are protocol
errors (JSON-RPC error responses), not tool execution errors
(isError: true). PR #165 moved all error cases to isError: true,
and PR #231 only restored protocol errors for unknown tools.
This fixes the remaining two cases: invalid arguments (-32602) and
server errors (-32603).
Ref: https://modelcontextprotocol.io/specification/2024-11-05/server/tools#error-handling
## Motivation and Context
`Server#notify_progress` broadcasts progress notifications to all connected clients.
Progress notifications are tied to a specific request's `progressToken` and have no
meaningful broadcast use case. Neither the Python SDK nor the TypeScript SDK provides
a server-level progress broadcast capability.
The MCP specification requires that progress notifications only reference tokens
provided in an active request:
> Progress notifications MUST only reference tokens that:
> - Were provided in an active request
> - Are associated with an in-progress operation
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
Broadcasting progress to all clients does not comply with this requirement,
since clients that did not originate the request have no matching `progressToken`.
The introduction of `ServerSession` for per-connection state (#275) made
this removal possible: `Progress` and `ServerContext` now use `notification_target:`
(a `ServerSession`) to route notifications to the originating session only.
### Changes
- Removed `Server#notify_progress` entirely. The method is no longer needed as
`notification_target` in `call_tool_with_args` now passes `session` directly
(nil when no session is available, in which case `Progress#report` and
`ServerContext#notify_log_message` are no-ops).
- Removed `ServerContext#notify_progress` as well. Progress notifications should
only be sent via `server_context.report_progress`, which enforces the correct
`progressToken` from the originating request.
- Added nil guards to `Progress#report` and `ServerContext#notify_log_message`
for when no session is available.
- Rewrote progress tests to use `ServerSession#handle` instead of
`Server#handle`, reflecting that progress notifications are always session-scoped.
- Removed 4 tests that called `server.notify_progress` directly as a public broadcast API.
- Removed `notify_progress` from the README.md notification methods list.
- Removed the "Server-Side: Direct `notify_progress` Usage" section from the README.md.
Progress notifications should be sent via `server_context.report_progress` inside tool handlers,
which automatically scopes them to the originating client session.
## Breaking Changes
Progress notifications are scoped to a specific request via `progressToken` per the MCP specification,
so the broadcast behavior of `Server#notify_progress` was a spec violation.
This is treated as a bug fix and is made without a deprecation period.
This aligns the Ruby SDK with the Python and TypeScript SDKs, neither of which provides
a server-level progress broadcast API.
`Server#notify_progress` and `ServerContext#notify_progress` are no longer available.
Users should use `server_context.report_progress` inside tool handlers instead, which
provides session-scoped delivery with the correct `progressToken`.
This feature was only recently introduced in mcp 0.9.0:
https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.9.0
So an early release should help limit its impact on adoption.
## Motivation and Context
The Ruby SDK uses a 1-Server-to-many-sessions model where session-specific state
(`@client`, `@logging_message_notification`) is stored on the shared `Server` instance.
This causes the last client to connect to overwrite state for all sessions,
and requires `session_id` to be threaded through method signatures for session-scoped notifications.
The Python SDK avoids this by creating a `ServerSession` per connection that wraps a shared `Server`.
Each session holds its own state and naturally scopes notifications through its own stream.
### Changes
- Added `MCP::ServerSession` class holding per-session state: client info,
logging configuration, and a `transport` reference for session-scoped
notification delivery.
- `StreamableHTTPTransport#handle_initialization` creates a `ServerSession`
per `initialize` request and stores it in the session hash.
- `handle_regular_request` routes requests through the session's
`ServerSession` instead of the shared `Server` directly.
- `Server#handle` and `Server#handle_json` accept an optional `session:`
keyword (`ServerSession` instance).
- `Server#init` stores client info on the session when available.
- `Server#configure_logging_level` stores logging config on the session.
- `ServerContext` and `Progress` accept a `notification_target:` which
can be either a `ServerSession` (session-scoped) or a `Server` (broadcast).
No `session_id:` parameter needed.
- `ServerContext#notify_progress` and `#notify_log_message` delegate to
the `notification_target` without `session_id` threading.
- `StdioTransport` creates a single `ServerSession` on `open`,
making the session model transparent across both transports.
### Design Decision
This follows the Python SDK's "shared `Server` + per-connection `Session`" pattern.
The `Server` holds configuration (tools, prompts, resources, handlers).
Each `ServerSession` holds per-connection state (client info, logging level, stream writer).
Notifications from `ServerSession` go only to that session's stream.
## How Has This Been Tested?
All existing tests pass. All conformance tests pass. Added tests verifying:
- Session-scoped log notification is sent only to the originating session.
- Session-scoped progress notification is sent only to the originating session.
- Each session stores its own client info independently.
- Each session stores its own logging level independently.
- `StdioTransport#open` creates a `ServerSession` and stores client info on it.
## Breaking Change
None for end users.
The public API (`Server.new`, `define_tool`,`server_context.report_progress`,
`server_context.notify_log_message`, etc.) is unchanged. The following internal API
changes affect only SDK internals:
- `ServerContext.new` now requires `notification_target:` instead of just`progress:`.
- `Progress.new` now takes `notification_target:` instead of `server:`.
- `Server#handle` and `Server#handle_json` accept an optional `session:` keyword.
## Motivation and Context
`server_context_with_meta` reads `@server_context` (the instance variable)
directly instead of calling the `server_context` accessor method.
This prevents subclasses from overriding `server_context`. Ffor example,
to provide thread-local context in multi-threaded servers like Puma.
Fixes#271.
## How Has This Been Tested?
Added a regression test and passed.
## Breaking Changes
None. This restores the expected behavior of `attr_accessor :server_context`.