## Motivation and Context
SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 spec release)
introduces Multi Round-Trip Requests: instead of issuing in-flight server-to-client JSON-RPC requests,
a server answers with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
(of `sampling/createMessage`, `roots/list`, and `elicitation/create` request shapes) and
an opaque `requestState`; the client fulfills the requests and re-issues the original request with
`inputResponses` and the echoed `requestState`.
The wire contract (the `resultType` discriminator and the `inputRequests`/`requestState` shape)
stayed stable across all three closed TypeScript prototype iterations (typescript-sdk#2062/#2065,
the v2-stateless stack, and #2251) and the Python draft (python-sdk#2322), but the server-side
suspend/resume mechanism is still unsettled in both SDKs (typescript-sdk#2251 was put on hold on 2026-06-08).
This change therefore implements only the stable, additive vocabulary and the client-side recognition,
leaving server emission and automatic resumption for a follow-up once the reference design lands:
- New `MCP::ResultType` module with `COMPLETE` and `INPUT_REQUIRED` constants documenting the `resultType` values.
- `MCP::Client` raises the new `MCP::Client::InputRequiredError` (exposing `input_requests`, `request_state`,
and the raw `result`) when any response carries `resultType: "input_required"`, instead of silently returning
a non-final result as if it were the answer. The check lives in the shared request path,
so every client method is covered. Servers on stable protocol versions never emit `resultType`,
so default behavior is unchanged.
Part of #382.
## How Has This Been Tested?
New tests in `test/mcp/client_test.rb`:
- `call_tool` raises `InputRequiredError` for an `input_required` result and exposes `input_requests`,
`request_state`, and the full raw result
- `call_tool` returns normally when `resultType` is `"complete"` and when it is absent
(wire-compat regression for stable-protocol servers)
- `list_tools` also raises for `input_required` results, proving the recognition covers the shared request path
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None for spec-compliant stable servers, which never send `resultType`. A response that does carry
`resultType: "input_required"` now raises `MCP::Client::InputRequiredError` instead of being returned as
a final result, which was always a misinterpretation of the draft semantics.
## Motivation and Context
The MCP specification requires an `initialize` request followed by `notifications/initialized`
before any other interaction. Issue #334 makes that handshake explicit on the stdio client,
matching the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`).
The change was staged to avoid a hard break. Phase 1 (#336) added `MCP::Client::Stdio#connect`
while keeping an implicit-init compatibility shim on the first `send_request`.
Phase 2 (#338) emitted a deprecation warning when that shim ran. The warning has shipped since 0.16.0,
so this final phase removes the shim.
## How Has This Been Tested?
`send_request` now raises a `RuntimeError` when `connect` has not been called, and no longer
auto-starts the subprocess. `connect` is the sole entry point that spawns the process and
performs the handshake, which avoids leaving an orphaned subprocess behind.
The stdio test suite was reworked to call `connect` explicitly, a regression test was added for
the new guard, and `rake test` passes (1145 runs, 0 failures).
## Breaking Changes
Stdio clients that send requests without calling `MCP::Client#connect` now raise a `RuntimeError`
instead of silently initializing. Call `client.connect` before the first request.
Closes#334
## Motivation and Context
The MCP specification (SEP-2243) requires the Streamable HTTP transport to mirror
selected JSON-RPC body fields into HTTP headers so that intermediaries such as load balancers,
gateways, and observability tooling can route and inspect requests without parsing the body.
`Mcp-Method` carries the JSON-RPC `method` and is required on all requests and notifications,
while `Mcp-Name` carries `params.name` (`tools/call`, `prompts/get`) or `params.uri` (`resources/read`).
The Ruby SDK client did not send these headers, so servers enforcing SEP-2243 rejected
its requests with `400 Bad Request`, breaking interoperability. This aligns the Ruby SDK with
the TypeScript and Python SDKs, both of which send these headers from their Streamable HTTP clients.
Header values that cannot be transmitted safely as-is, such as non-ASCII text, control characters
(including CR/LF, which would otherwise allow header injection), or significant leading or
trailing whitespace, are wrapped per SEP-2243 as `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
The encoding uses `Array#pack("m0")` to avoid depending on the `base64` gem.
Resolves#366
## How Has This Been Tested?
Client tests cover deriving `Mcp-Name` from `params.name` (`tools/call`, `prompts/get`) and
falling back to `params.uri` (`resources/read`), both symbol- and string-keyed params,
requests and notifications without a name or uri sending only `Mcp-Method`, and non-Hash params sending
only `Mcp-Method`. Value encoding is covered for non-ASCII names, surrounding whitespace, CR/LF
(verifying no header injection), and values that already match the `=?base64?...?=` sentinel.
## Breaking Changes
None. The change is purely additive: existing requests now carry the required `Mcp-Method` and `Mcp-Name` headers
alongside the headers already sent. The `x-mcp-header` / `Mcp-Param-*` custom headers remain out of scope,
matching the current state of the TypeScript and Python SDKs.
## Motivation and Context
The server-side half of MCP `notifications/cancelled` shipped earlier and lets handlers
observe cancellation via `server_context.cancelled?` / `raise_if_cancelled!`.
This PR delivers the client-side equivalent that was deferred at the time:
an `MCP::Client` user can now cancel a request they have already issued and have
the calling thread wake up, matching the Python SDK
(`anyio.CancelScope`) and TypeScript SDK (`AbortSignal`) ergonomics.
The recommended pattern is to pass an `MCP::Cancellation` token into the request method,
run the request on a worker thread, and call `cancellation.cancel(reason:)` from another thread.
The cancelling thread sends `notifications/cancelled` to the server, and the calling thread is
woken up with `MCP::CancelledError`:
```ruby
cancellation = MCP::Cancellation.new
Thread.new do
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
rescue MCP::CancelledError
# cleanup
end
cancellation.cancel(reason: "user pressed cancel")
```
For low-level use, `Client#cancel(request_id:, reason:)` sends the notification without managing
the calling thread, and `Client#generate_request_id` lets callers pre-allocate an id before kicking off
a request on another thread. The `request_id:` and `cancellation:` keywords are accepted by
every request method (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`,
`list_resource_templates`, `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`,
`complete`, `ping`).
When `cancellation:` is supplied, the actual blocking `transport.send_request` runs on a worker thread;
the calling thread waits on a `Queue` woken either by the response or by the cancel signal
(whichever arrives first - the same race contract as the server-side `StreamableHTTPTransport#cancel_pending_request`).
On a normal completion the `on_cancel` hook is deregistered so a late cancel does not emit a stray `notifications/cancelled`.
The worker thread is *not* force-killed when a cancel wins the race; it stays blocked on the underlying I/O until
the transport actually returns (or the user closes it). For `Client::HTTP` the leak resolves as soon as
the server sends any response; for `Client::Stdio` the user may need to call `client.transport.close`
if the server stops responding entirely.
`Client::Stdio` and `Client::HTTP` gain `send_notification(notification:)` so `Client#cancel` can deliver
the JSON-RPC notification through the existing transport plumbing. Custom transports that do not implement
`send_notification` cause `Client#cancel` to raise `NoMethodError`.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
## How Has This Been Tested?
`test/mcp/client_test.rb` covers `Client#cancel` payload (with and without `reason`),
`Client#generate_request_id` returning distinct UUIDs, every request method threading `request_id:` through,
and the full `cancellation:` flow:
- Returns the response when no cancel happens (no behaviour regression for callers that do not pass a token).
- Pre-cancelled token raises `MCP::CancelledError` immediately and never reaches the transport
(no stray request, no stray notification).
- Mid-flight cancel raises `MCP::CancelledError` carrying the supplied reason and sends `notifications/cancelled`
through the transport.
- Late cancel after a normal completion does not emit a stray notification
(the `on_cancel` hook is deregistered in the `ensure` block).
`test/mcp/client/stdio_test.rb` covers `send_notification` writing the JSON line through the spawned process
and returning `nil` without waiting for a response.
`test/mcp/client/http_test.rb` covers `send_notification` POSTing the body, tolerating HTTP 202 Accepted,
and surfacing Faraday errors as `RequestHandlerError`.
## Breaking Change
None. All new keywords (`request_id:`, `cancellation:`) on request methods are optional and default to `nil`.
Existing callers that do not pass them get the original synchronous behaviour. Custom transports that already
implement `send_request(request:)` continue to work; only callers of `Client#cancel` need a transport
that responds to `send_notification`, and the failure mode is a clear `NoMethodError` rather than silent breakage.
The MCP specification constrains `Annotations.audience` to an array of
`Role` values (`"user"` or `"assistant"`), but the Ruby SDK accepts any
value. The other official SDKs already enforce this (TypeScript, Python,
and PHP).
This follows the existing pattern in `MCP::Icon`, which validates its
constrained `theme` value in the constructor and raises `ArgumentError`.
Added tests in `test/mcp/annotations_test.rb`:
- valid `audience` (`["user"]`, `["assistant"]`, `["user", "assistant"]`,
and an empty array) is accepted
- an unknown role, a mixed valid/invalid array, or a non-array `audience`
raises `ArgumentError`
Existing tests used `["developers"]`, which is not a valid `Role`, and are
updated to `["user"]`.
The full unit suite and RuboCop pass locally.
Strictly speaking this is a breaking change: a caller that currently passes
an invalid `audience` (e.g. `["developers"]`) will now get an
`ArgumentError`. Such values already violate the MCP specification, and
`nil` / valid-role values are unaffected.
## Motivation and Context
The gemspec already declares `required_ruby_version >= 2.7.0`, but `ServerContext` used
the `...` arguments forwarding syntax in `method_missing`. That syntax was added in Ruby 2.7.3,
so requiring the gem raised a `SyntaxError` on Ruby 2.7.0 through 2.7.2, and the library could not
be loaded at all.
RuboCop did not catch this because its Parser backend runs on Ruby 2.7.8, where the syntax is valid.
CI missed it too because the test matrix tested `2.7`, which resolves to the latest 2.7.x rather than
the declared minimum.
## How Has This Been Tested?
Forward arguments explicitly with `*args, **kwargs, &block` so the method loads on Ruby 2.7.0,
and pin the CI test matrix to 2.7.0 so the suite runs against the declared minimum instead of
the latest 2.7.x.
Running the suite on 2.7.0 also requires a `Hash.ruby2_keywords_hash?` shim in the test helper,
since mocha would otherwise raise a `NoMethodError` on that version.
## Breaking Changes
None.
Closes#418
## 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
This resolves the following build failure that occurs when using Ruby 3.1 with I18n 1.15.0:
```console
$ bundle exec rake
(snip)
/Users/koic/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/i18n-1.15.0/lib/i18n.rb:58:
in `config': undefined method `[]' for Fiber:Class (NoMethodError)
current = Fiber[:i18n_config] || self.config = I18n::Config.new
^^^^^^^^^^^^^^
from /Users/koic/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/i18n-1.15.0/lib/i18n.rb:88:in `load_path'
```
https://github.com/modelcontextprotocol/ruby-sdk/actions/runs/27724973112/job/82025136923
According to the report in https://github.com/ruby-i18n/i18n/issues/735,
this issue has been fixed in I18n 1.15.1 and later.
However, the already released I18n 1.15.0 still triggers the build error.
While this could potentially be addressed in a better way on the I18n side,
this change updates the Gemfile to avoid using I18n 1.15.0, which is known to cause the issue.
## How Has This Been Tested?
The build error described above has been resolved.
## Breaking Changes
None.
## Motivation and Context
The MCP 2025-03-26 authorization spec predates Protected Resource Metadata:
the MCP server itself acted as the authorization base URL, its RFC 8414 metadata was fetched from
the server origin, and the spec's "Fallbacks for Servers without Metadata Discovery" section
required clients to use the default endpoints `/authorize`, `/token`, and `/register` relative to
the authorization base URL when no metadata was published. Both the TypeScript SDK
(`discoverOAuthServerInfo` falls back to the server base URL on PRM failure; `startAuthorization` /
`executeTokenRequest` / `registerClient` default the endpoint paths) and the Python SDK
(`build_oauth_authorization_server_metadata_discovery_urls(None, ...)` plus the same endpoint defaults)
keep this as client-side backwards compatibility, and the `auth/2025-03-26-oauth-metadata-backcompat` and
`auth/2025-03-26-oauth-endpoint-fallback` conformance scenarios exercise it.
The Ruby flow previously raised as soon as PRM discovery failed, so both scenarios were
expected conformance failures. This change adds the same fallback:
- `Flow#locate_authorization_server` first attempts PRM discovery; on any discovery failure
(404s, network errors, malformed documents, matching the TypeScript and Python SDKs' broad fallback)
the MCP server's origin becomes the legacy authorization base URL. The Communication Security check
(HTTPS or loopback) still applies to that origin, and PRM documents that parse correctly keep
their strict shape validation.
- `Flow#authorization_server_metadata` fetches RFC 8414 metadata from the base URL.
On the legacy path the `issuer` byte-match is skipped: the 2025-03-26 spec predates that requirement,
and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs
from the discovery origin (neither reference SDK validates the issuer on this path). On the modern path
the byte-match is unchanged.
- When even the metadata document is absent, synthetic metadata carrying the legacy spec's default endpoints
is used, with PKCE S256 assumed (the legacy spec mandates PKCE; the TypeScript and Python SDKs hardcode S256
on this path too). Endpoint HTTPS checks still apply.
- `run!` and `refresh!` share the new discovery helpers, and both 2025-03-26 scenarios are removed from
`conformance/expected_failures.yml`.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb`:
- the metadata-backcompat shape: no PRM, AS metadata at the server origin with `/oauth`-prefixed endpoints
and a mismatched `issuer` completes the flow against those endpoints (no issuer error)
- the endpoint-fallback shape: no metadata at all registers, authorizes, and exchanges the code at `/register`,
`/authorize`, and `/token` on the origin, still sending `code_challenge` (S256) and `code_verifier`
- a remote plain-http origin is rejected as the legacy authorization base
- strict-mode regression: with PRM present, a mismatched `issuer` still aborts the flow
- a malformed PRM (top-level JSON array) now selects the legacy path instead of leaking a `TypeError`,
and surfaces a domain error when that path also dead-ends
Two existing tests that asserted PRM failure was terminal were updated to dead-end the legacy path explicitly,
preserving their original intent (single flow attempt, domain error type).
## Breaking Changes
None for spec-compliant 2025-06-18+ servers, whose PRM-based discovery and issuer validation are unchanged.
Servers that previously failed hard during PRM discovery now get one legacy discovery attempt before the flow errors,
which can only turn previously failing flows into working ones.
## Motivation and Context
SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release)
makes MCP sessionless: every Streamable HTTP POST must be fully self-contained,
with no protocol-level session state shared between requests.
`StreamableHTTPTransport` already implements the SEP's transport surface via
`stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405,
DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak:
stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and
`@client_capabilities` onto the shared `Server` instance.
Concurrent stateless requests could therefore observe another client's identity,
and the data persisted across requests, which is exactly what SEP-2567 forbids.
The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack)
solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture:
- Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now
run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`),
so client info, logging level, and initialized state live only for the duration of that POST.
Repeated `initialize` requests are naturally permitted because each POST gets a fresh,
never-initialized session.
- `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising.
With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message`
would otherwise route every call into the exception reporter; non-delivery matches how
these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises,
as those are genuinely unsupported without a stream.
Resolves#388.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:
- a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched
(the leak regression)
- repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header
- a tool calling `server_context.report_progress` under stateless mode returns its result normally and
the exception reporter is never invoked
- the existing "stateless mode does not support server-sent events" test is updated to assert
the new `false` return instead of the removed raise
All other existing stateless-mode tests pass unchanged.
## Breaking Changes
`StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`.
The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation,
and the boolean return matches the method's documented semantics in every other non-delivery case.
Default (session-oriented) mode is unchanged.
## 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
The MCP specification constrains `Annotations.priority` to the 0–1 range
(`@minimum 0`, `@maximum 1`), but the Ruby SDK accepts any value. The other
official SDKs already enforce this (TypeScript, Python, and PHP).
This follows the existing pattern in `MCP::Icon`, which validates its
constrained `theme` value in the constructor and raises `ArgumentError`.
## How Has This Been Tested?
Added tests in `test/mcp/annotations_test.rb`:
- valid `priority` at the lower (0) and upper (1) bounds is accepted
- out-of-range `priority` (above 1, below 0) raises `ArgumentError`
The full unit suite and RuboCop pass locally.
## Breaking Changes
Strictly speaking yes: a caller that currently passes an out-of-range
`priority` (e.g. `priority: 99`) will now get an `ArgumentError`. Such values
already violate the MCP specification, and `nil` / in-range values are
unaffected.
## Motivation and Context
SEP-837 (modelcontextprotocol/modelcontextprotocol#837, merged for the 2026-07-28 spec release)
updates the authorization spec to require MCP clients to specify an appropriate OIDC `application_type`
(`"native"` vs `"web"`) during Dynamic Client Registration, so the authorization server can apply
the matching redirect URI policy and native and web clients do not collide on redirect URI validation rules.
This mirrors the approach taken by the TypeScript SDK (typescript-sdk#2266) and the Python SDK (python-sdk#2784):
- `MCP::Client::OAuth::Discovery.infer_application_type(redirect_uris)` returns `"native"` when every redirect URI
is a native-app URI (a custom non-http(s) scheme per RFC 8252 Section 7.1, or an http(s) URI with a loopback host
per RFC 8252 Section 7.3) and `"web"` otherwise, including for nil, empty, or unparseable input.
Loopback detection reuses the existing `loopback_host?` helper, so lookalike hosts such as `localhost.example.com`
or `127.attacker.com` are not treated as native.
- `Flow#ensure_client_registered` now posts the metadata through the new `registration_client_metadata` helper,
which merges the inferred `application_type` into the DCR request body only when the user did not set
one explicitly (symbol or string key); an explicit value always wins.
Resolves#375.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/discovery_test.rb` cover the inference: loopback http URIs (IPv4, IPv6, `localhost`),
custom schemes, https URIs, mixed lists, the `localhost.example.com` lookalike, nil/empty input, and unparseable URIs.
New tests in `test/mcp/client/oauth/flow_test.rb` assert on the actual DCR request body via WebMock `assert_requested`:
loopback redirect URI registers `"application_type": "native"`, an HTTPS redirect URI registers `"web"`,
and an explicit `application_type` (symbol- or string-keyed) is never overridden.
## Breaking Changes
None. The DCR request body gains an `application_type` member only when the user has not specified one;
RFC 7591 requires authorization servers to accept and use known metadata fields, and OIDC-registration servers
already default this field to `"web"` when absent.
## 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
The MCP specification defines an optional `annotations` field on both the
`Resource` and `ResourceTemplate` types, but the Ruby SDK is the only official
SDK that does not wire it up — TypeScript, Python, and PHP all implement it.
https://modelcontextprotocol.io/specification/2025-11-25/server/resources#annotations
The `MCP::Annotations` class already exists; this commit connects it. The field
accepts an `MCP::Annotations` instance rather than a raw hash, for consistency
with other typed fields and to get `lastModified` key normalization for free.
## How Has This Been Tested?
Added tests in `test/mcp/resource_test.rb` and
`test/mcp/resource_template_test.rb`:
- `#to_h` omits `annotations` when nil
- `#to_h` includes `annotations` when present (also asserting that
`last_modified` is serialized as `lastModified`)
The full unit suite and RuboCop pass locally.
## Breaking Changes
None. `annotations:` is a new optional keyword argument defaulting to `nil`,
and it is omitted from `#to_h` output when not set.
## 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.