## Motivation and Context
SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.
The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):
- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
and never falls back to the GET stream.
Resolves#381.
## How Has This Been Tested?
- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
`create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
"attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
stays clean; their assertions are unchanged.
## Breaking Changes
None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
## Motivation and Context
`bundle exec rake` fails on main with four errors in `streamable_http_transport_test.rb`:
```
NoMethodError: undefined method '[]=' for nil
streamable_http_transport_test.rb:5119 (install_mutex_probe_stream)
```
This is a semantic conflict between two merged PRs. #451 made the server reject `initialize` requests
that lack the required `protocolVersion`, `capabilities`, and `clientInfo` params with -32602. #449,
merged after but developed before it, added the "writes outside the mutex" tests,
whose `initialize_test_session` helper sends an `initialize` request with no params.
That request is now rejected, no session is created, so `@sessions[session_id]` is nil and installing
the probe stream raises `NoMethodError`.
The fix adds `params: initialize_params` to the helper's request body, matching the pattern used
by every other `initialize` request in the file via `InitializeParamsTestHelper`.
## How Has This Been Tested?
`bundle exec rake` now passes: 1318 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The four previously failing tests are the regression coverage.
## Breaking Changes
None. Test-only change.
## Motivation and Context
Fixes https://github.com/modelcontextprotocol/ruby-sdk/issues/450
The MCP schema requires `protocolVersion`, `capabilities`, and `clientInfo` on `initialize` params,
and `uri` on `resources/subscribe` and `resources/unsubscribe` params. The server accepted requests with
these fields absent: `initialize` silently negotiated the server's default protocol version,
and `resources/subscribe` returned an empty success result. Both now return `-32602` Invalid params,
matching the Python SDK, which rejects such requests during pydantic validation.
The `uri` check runs before dispatch so it also covers handlers registered via `resources_subscribe_handler`
and `resources_unsubscribe_handler`.
A `protocolVersion` value that is present but unsupported still negotiates the server's fallback version,
as the spec's lifecycle section requires.
## How Has This Been Tested?
Reproduced the issue's stdio repro steps: both malformed requests now return `-32602` instead of succeeding.
## Breaking Changes
Clients that send `initialize` without `protocolVersion`, `capabilities`, or `clientInfo`,
or `resources/subscribe` / `resources/unsubscribe` without `uri`, now receive `-32602` Invalid params
instead of a success response. Such requests violate the MCP schema, but any code relying on
the previous lenient behavior (for example, test harnesses sending bare `initialize` requests)
must now include the required fields.
## Motivation and Context
`StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`.
The send paths wrote to an SSE stream while holding that lock: the targeted and
broadcast branches of `send_notification`, the server-to-client `send_request`,
and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream`
(a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`.
A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly,
the server-side `write` waits until the socket buffer drains.
Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking):
new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions
all wait behind one slow reader. The broadcast path holds the lock across every session,
so one slow stream also delays delivery to the sessions after it.
The cleanup paths already release the lock before touching a stream: they collect `streams_to_close`
and call `close` outside `synchronize`. This change applies the same discipline to the writes.
- Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers
the pending response there), then releases the lock before writing.
- A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`,
which mutates `@sessions` under `@mutex` and closes the affected streams outside it.
A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session,
matching the previous behavior.
- `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`;
the return values (true/false for a session, the delivered count for a broadcast) are unchanged.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records,
via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification,
broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now.
The existing send and cleanup tests still pass.
## Breaking Changes
None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
## Motivation and Context
A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised
on a non-object value parsed from an incoming frame:
- `JsonRpcHandler.handle` maps each element of a batch array through `process_request`,
which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]`
or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`,
so the error escaped; over stdio it escaped the `StdioTransport#open` read loop.
The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio.
- `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`,
which raises `NoMethodError` when the server emits a non-object frame.
Only `JSON::ParserError` was rescued, so one malformed line from the server escaped.
Both are robustness gaps: a malformed frame should be reported or skipped, not raise
an unhandled exception out of the transport.
- `process_request` now returns an `INVALID_REQUEST` error response for a non-object request
before touching `request[:id]`, so a malformed batch element becomes a normal per-element error.
- `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response,
so an unexpected handling error still produces a JSON-RPC reply.
- `read_response` skips a non-object frame the same way it skips a frame without an id.
## How Has This Been Tested?
New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements
(`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element,
and `handle_json("[[]]")` returning an error instead of raising. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and
asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb`
has the server emit non-object frames before the response and asserts the client skips them
and still returns the response.
## Breaking Changes
None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
## Motivation and Context
Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions
while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block
with manual URI routing.
`MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool`
and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`,
`size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods
and `MCP::Server#define_resource` / `#define_resource_template`.
The default `resources/read` handler now routes requests automatically: an exact URI match calls
the class-based resource's `contents` method, and class-based resource templates match
simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments.
`contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered
and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164.
## How Has This Been Tested?
- Added regression tests covering the class-level DSL, URI template matching, auto-routing,
`server_context:` opt-in, error responses, and backward compatibility of the no-op default.
- Verified end-to-end with a smoke script exercising exact-match reads, template-match reads,
unknown URIs, and legacy instance-only setups.
## Breaking Changes
No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for
setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds
the internal URI index.
Closes#98
## Motivation and Context
Resolves the `elicitation-sep1034-client-defaults` client conformance scenario
(SEP-1034, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034).
The Ruby HTTP client could not receive server-to-client requests at all:
it never opened the spec's standalone GET SSE listening stream, and SSE events that
were not responses were discarded. A server sending `elicitation/create` during a tools/call
(such as the conformance harness, which is built on the TypeScript SDK server)
either dropped the request silently or deadlocked the tool call waiting for an answer that
could never arrive.
This change adds three pieces:
### Server-to-client request dispatch
`MCP::Client::HTTP#on_server_request(method) { |params| ... }` registers a handler;
when a JSON-RPC request (a message with both `method` and `id`) arrives on any SSE stream,
the handler's return value is POSTed back to the server as the JSON-RPC `result`
(the server ACKs with 202 Accepted, per the transport spec).
Methods without a handler are answered with a `-32601` method-not-found error.
A handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code
(for example `-1` for a rejected sampling request, per the sampling spec),
mirroring the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return;
any other error is answered with a `-32603` internal error. In every case the server is not left waiting,
matching how the TypeScript and Python SDKs convert handler failures into JSON-RPC error responses.
### A standalone GET SSE listening stream
Registering a handler (once connected) opens the spec's listening stream on a background thread,
because servers deliver requests that are not tied to a client request on that stream -
the TypeScript SDK's `StreamableHTTPServerTransport` silently drops them when no GET stream is open.
Mirrors the TypeScript and Python SDK clients, which start listening right after the `initialize` handshake
without awaiting stream establishment. Reconnection semantics also follow both SDKs:
a gracefully closed stream is retried indefinitely after the server's `retry:` interval,
while consecutive connection failures (HTTP errors and idle read timeouts) stop the listener once
they reach the reconnection attempt cap, with a successful stream resetting the count;
a 405 stops immediately without retrying, since it is the spec's answer from a server that
offers no listening stream. The listening GET uses a 5-minute read timeout matching
the Python SDK's `sse_read_timeout` default. `close` terminates the thread.
### SEP-1034 defaults
`MCP::Client::Elicitation.apply_defaults` fills fields omitted from the elicitation content with
the `default` values declared in `requestedSchema` properties, never overwriting provided values
and skipping properties without a default. This follows the Python SDK, which applies defaults in
the elicitation callback; the TypeScript SDK's automatic `applyElicitationDefaults`
(gated on the `elicitation.form.applyDefaults` capability) is a possible follow-up.
`MCP::Client#on_elicitation` registers a handler for `elicitation/create` as a convenience.
The conformance client gains an `elicitation-sep1034-client-defaults` branch that connects with
`capabilities: { elicitation: {} }`, accepts the elicitation with defaults applied, and calls
the harness's `test_client_elicitation_defaults` tool; the scenario is removed from the expected failures baseline.
## How Has This Been Tested?
New unit tests covering: handler dispatch from a request's POST SSE stream with
the response body and 202 ACK verified, the `-32601` answer for unregistered methods,
the `-32603` answer for a raising handler, the custom-code answer when a handler raises
`ServerRequestError`, listener startup from both registration-after-connect and
connect-with-registered-handlers, listener shutdown after consecutive connection failures with
the count resetting after a successful stream and an immediate stop on 405,
and `apply_defaults` for all five SEP-1034 property types (string, integer, number, enum, boolean),
non-overwriting of provided values, omission of properties without defaults, and symbol-keyed schemas
## Breaking Changes
None. The listening stream and its background thread are created only when a server-request handler is registered;
clients that never call `on_server_request` / `on_elicitation` keep the existing single-request behavior.
## Motivation and Context
Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:
- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
in the reporter's measurement. The path runs before session validation,
so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
so a peer that never emits a newline grows a single String until the process is OOM-killed.
This matters when a host pipes an untrusted server's stdout into an unconstrained process.
The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.
- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
rejects a declared `Content-Length` over the cap before reading, and also bounds
the actual read to one byte past the cap so a missing or spoofed `Content-Length`
(chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
`max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
The constructor validates the new option as a positive Integer, since `nil` or a non-positive
value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
a separate advisory, so this change carries the remaining piece of the reported stdio fix:
an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
fails cleanly instead of parsing a truncated frame.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).
## Breaking Changes
None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
## Motivation and Context
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates
an incoming request by session existence and idle timeout only, with no binding to a client identity.
An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST
a `tools/call` on that session and the server executes it, silently poisoning the victim's session.
The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path,
so this is not covered by it.
Binding a session to a user is fundamentally the deploying application's responsibility:
the transport hands the server only the JSON-RPC body string and never receives the authenticated principal
(`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as
a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present.
This gives the Ruby SDK the seam to do the same:
- A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }`
on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects
with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses,
not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel
a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover.
`initialize` is exempt because it establishes the session. An application that has an authenticated principal
uses the validator to enforce that the caller owns the session; without one, ownership is not enforced.
- A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header
is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when
both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC)
is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker.
Enforcing ownership against a determined attacker requires the validator.
Stateless mode retains no sessions, so neither check applies to it.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs
from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed;
a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID;
the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests;
and stateless mode never consults the validator.
## Breaking Changes
None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both
the session and the request carry an `Origin` and they differ, so existing applications are unaffected.
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
## Motivation and Context
SEP-1865 "MCP Apps - Interactive User Interfaces for MCP" is Final on the Extensions Track:
(https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) an optional extension,
negotiated through the SEP-2133 `capabilities.extensions` mechanism, that lets a server ship
HTML user interfaces which the host renders for tool results.
The normative spec lives in the ext-apps repository (`specification/2026-01-26/apps.mdx`).
The Python SDK implements it in-SDK (python-sdk#3003, the `Apps` extension); the TypeScript
ecosystem ships it as the separate `@modelcontextprotocol/ext-apps` package on top of
the core SDK's SEP-2133 support.
Server-side support is a thin convention layer over primitives this SDK already has
(SEP-2133 extension capabilities, `_meta` on tools and resources, free-form resource URIs
and MIME types). New `MCP::Apps` module:
- Wire vocabulary constants shared with the reference implementations: `EXTENSION_ID`
(`"io.modelcontextprotocol/ui"` - note `/ui`, not `/apps`), `RESOURCE_MIME_TYPE`
(`"text/html;profile=mcp-app"`), `URI_SCHEME` (`"ui://"`), and the legacy flat meta key
`RESOURCE_URI_META_KEY` (`"ui/resourceUri"`).
- `Apps.capability(mime_types:)` builds the `capabilities.extensions` fragment for
`support_extensions` on the server or `connect` on the client.
- `Apps.ui_resource(uri:, name:, ...)` builds an `MCP::Resource` for a UI template,
enforcing the spec's MUSTs (a `ui://` URI; the parameterized HTML MIME type by default).
- `Apps.tool_meta(resource_uri:, visibility:, meta:, legacy:)` builds the `_meta.ui.resourceUri`
linkage (optionally also emitting the legacy flat alias, matching the reference server helper
that keeps both shapes in sync), merged non-destructively into caller-supplied meta.
- `Apps.client_supports?(client_capabilities, mime_type:)` gates the text-only fallback
the extension requires for clients that did not declare the capability (symbol/string tolerant).
Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge methods,
consent for UI-initiated calls) is HOST responsibility and out of scope for a server SDK;
a server only ever receives ordinary `resources/read` and `tools/call` requests.
The README section states this boundary.
Resolves#376.
## How Has This Been Tested?
New `test/mcp/apps_test.rb` pins the wire vocabulary and covers the capability fragment,
`ui_resource` scheme validation and MIME default, `tool_meta` non-destructive merging,
visibility, the legacy alias, and `client_supports?` across symbol/string keys, mimeTypes narrowing,
and missing declarations.
New integration tests in `test/mcp/server_test.rb` drive a full server: the `initialize` result
advertises the extension, tools/list serializes `_meta.ui.resourceUri`, `resources/read` serves
the HTML template, and `client_supports?` distinguishes hosts with and without the declared extension.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Also verified end-to-end
over stdio: a client connecting with the extension declared negotiates the capability,
discovers the tool linkage via `_meta.ui.resourceUri`, and reads the `ui://` template
as `text/html;profile=mcp-app` content.
## Breaking Changes
None. Purely additive helper module; no existing code paths change.
## Motivation and Context
CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.
This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.
Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.
Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.
## How Has This Been Tested?
Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.
Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.
Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.
The full test suite and RuboCop pass.
## Breaking Changes
None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
## Motivation and Context
In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored
a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker
could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until
memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client.
The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count,
so the insecure behavior was the default.
Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python,
and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults
in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below):
- `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default
and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode
retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from
an explicit `nil`.
- A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under
the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions
the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired),
and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects:
established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions
while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS,
which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode.
The value is validated as a positive Integer or `nil`.
## How Has This Been Tested?
New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and
the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep
no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session
is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out,
and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to
the new secure default.
## Breaking Changes
Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions
lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require
the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
## Motivation and Context
SEP-2352 (modelcontextprotocol/modelcontextprotocol#2352, merged for the 2026-07-28 spec release) clarifies
authorization server binding and migration: clients MUST key persisted client credentials by the issuing
authorization server's issuer identifier, MUST NOT reuse credentials across different authorization servers,
and MUST re-register when the server's authorization server changes. CIMD `client_id`s (HTTPS URLs) are
explicitly portable across authorization servers, and a mismatch SHOULD surface an error rather than silently use
mismatched credentials.
The Ruby flow previously reused any stored `client_information` with a `client_id` unconditionally,
so a server that migrated to a new authorization server would have the old server's credentials
(and its dead tokens) replayed forever. This change follows the detect-change, invalidate,
and re-register approach that both reference SDKs converged on (typescript-sdk#2348/#2358, merged 2026-06-24;
python-sdk#2933/#2936/#2946, which uses the same proactive compare-before-reuse design as this change)
while keeping the 4-method storage contract intact by stamping the binding into the persisted hash:
- Successful Dynamic Client Registration now persists the credentials with an `"issuer"` member set to
the validated AS metadata `issuer` (which `ensure_issuer_matches!` has already pinned to the discovery URL).
- `Flow#ensure_client_registered` reuses stored credentials only when the stored issuer matches the current one.
Credentials without an issuer binding (data persisted by older SDK versions, or user-supplied pre-registered credentials)
are bound to the current issuer on first use. A portable CIMD `client_id` is reused and re-bound.
Any other issuer mismatch discards the stale registration and its tokens (tokens minted by the old AS are dead at the new one)
and re-registers via CIMD/DCR.
- `Flow#refresh!` raises `AuthorizationError` instead of replaying another authorization server's credentials at the token endpoint;
`MCP::Client::HTTP#attempt_refresh` already rescues that error and falls back to the full flow, which performs
the discard-and-re-register.
- The `client_credentials` grant (machine-to-machine) reads static pre-registered credentials without going through
`ensure_client_registered`, so it would have silently sent another authorization server's credentials after a migration.
`run_client_credentials!` now surfaces an `AuthorizationError` when the stored `issuer` mismatches the current authorization server,
implementing the spec's "SHOULD surface an error" (the TypeScript SDK raises `AuthorizationServerMismatchError` here).
Re-registration is not an option for pre-registered m2m credentials, so the operator must update the stored credentials;
credentials without a recorded issuer keep working unchanged.
Two adjacent hardenings from the reference implementations were checked and need no change here:
the legacy-fallback issuer stamp is self-consistent because this SDK's fallback registers at the same origin it
uses as the issuer (the cross-origin never-reached-AS binding python-sdk#2946 closed cannot arise),
and preserving a non-rotated `refresh_token` across refreshes already exists via `preserve_refresh_token`.
The TypeScript SDK's redirected-to-issuer callback gate is not adopted: the RFC 9207 `iss` validation (SEP-2468) covers that mix-up leg.
Resolves#385.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb`:
- stored credentials with a matching issuer skip DCR
- an issuer mismatch re-registers and persists the new `client_id` with the new issuer
- the stale registration's tokens are cleared before re-registration (observed by aborting the flow at the DCR step)
- legacy credentials without an issuer (string- and symbol-keyed) are reused and bound to the current issuer on first use
- a CIMD `client_id` with a mismatched issuer is reused without DCR and re-bound
- fresh DCR persists the issuer binding
- `refresh!` succeeds with a matching stored issuer and raises (without contacting the token endpoint) with a mismatched one
- the `client_credentials` grant succeeds with a matching stored issuer and raises (without contacting the token endpoint) with
a mismatched one
All existing flow, HTTP OAuth, and provider tests pass unchanged.
## Breaking Changes
None for the documented storage contract: `client_information` hashes gain an additional `"issuer"` member that storages persist opaquely,
and credentials stored without one keep working via first-use binding. Behavior changes only in the previously broken case where
the authorization server changed, which now re-registers (or, for static machine-to-machine credentials, surfaces an error) instead of
replaying the old server's credentials.
## 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`.
## 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 `io.modelcontextprotocol/oauth-client-credentials` extension
(SEP-1046, modelcontextprotocol/modelcontextprotocol#1046) specifies
two client authentication methods for the `client_credentials` grant:
`client_secret_basic` and the RECOMMENDED `private_key_jwt`
(RFC 7523 Section 2.2 JWT client assertion). The previous commit added
the grant with secret-based authentication; this adds the JWT method,
which is what the `auth/client-credentials-jwt` conformance scenario exercises.
This mirrors the merged implementations in the TypeScript SDK
(`PrivateKeyJwtProvider`, typescript-sdk#1157) and the Python SDK
(`PrivateKeyJWTOAuthProvider` / `SignedJWTParameters`, python-sdk#1663),
adapted to this SDK's provider design:
- New `MCP::Client::OAuth::JWTClientAssertion` builds the RFC 7523 compact
JWS with openssl, keeping the SDK free of a JWT gem dependency
(the TypeScript and Python SDKs use jose and PyJWT). Claims follow SEP-1046:
`iss` and `sub` carry the client_id, `aud` carries the authorization
server's issuer identifier, plus `exp` (300-second lifetime, matching both SDKs),
`iat`, and a unique `jti`. ES256 and RS256 are supported; ES256 signatures are
converted from OpenSSL's ASN.1 DER form to the raw 64-byte `r || s` encoding
JWS requires (RFC 7518 Section 3.4).
- `ClientCredentialsProvider` accepts
`token_endpoint_auth_method: "private_key_jwt"` with `private_key:`
(PEM string or `OpenSSL::PKey::PKey`) and a required `signing_algorithm:`
(no default, so an algorithm mismatch with the server's
`token_endpoint_auth_signing_alg_values_supported` fails loudly at construction;
both reference SDKs also take the algorithm explicitly).
The key stays on the provider and is never written to `storage`;
a throwaway assertion is signed at construction to fail fast on an unparseable key
or a key/algorithm mismatch.
- `Flow#post_to_token_endpoint` sends `client_assertion_type` and a freshly
signed `client_assertion` for `private_key_jwt`,
using the `ensure_issuer_matches!`-validated issuer as the audience,
and omits `client_id` from the body per SEP-1046
(identity travels in the assertion's claims).
- The conformance client builds a `private_key_jwt` provider
when the harness injects `private_key_pem`, defaulting the algorithm to
ES256 like the TypeScript and Python conformance clients,
and `auth/client-credentials-jwt` is removed from
`conformance/expected_failures.yml`.
Scope notes: only ES256 and RS256 are supported for now (the TypeScript and
Python SDKs accept whatever their JWT libraries support);
adding further algorithms is additive. There is also no injection point yet for
externally signed assertions (the TypeScript SDK's `StaticPrivateKeyJwtProvider` and
the Python SDK's `assertion_provider` callback cover KMS/HSM-held keys);
that is a separate follow-up.
## How Has This Been Tested?
New `test/mcp/client/oauth/jwt_client_assertion_test.rb` covers the JWS construction:
header and RFC 7523 claims, unpadded base64url segments,
ES256 signature round-trip verification (raw -> DER -> `OpenSSL::PKey::EC#verify`),
RS256 verification, PEM and parsed-key input, lifetime override, unique `jti` per assertion,
and rejection of unsupported algorithms, key/algorithm mismatches
(including a P-384 key with ES256), unparseable PEM, and public-only keys.
New tests in `test/mcp/client/oauth/client_credentials_provider_test.rb` cover
the provider surface: `private_key_jwt` construction, the key and
secret never reaching `client_information`, required `private_key` / `signing_algorithm`,
rejection of a simultaneous `client_secret`, fail-fast on key/algorithm mismatch,
and `client_assertion(audience:)` producing a JWT with the expected claims.
A new test in `test/mcp/client/oauth/flow_test.rb` runs the full grant against WebMock stubs
and asserts the token request carries `grant_type=client_credentials`,
the RFC 7523 `client_assertion_type`, an assertion whose `iss`/`sub` are the client_id
and whose `aud` is the issuer, the RFC 8707 `resource`, and neither `client_id` nor
any secret in the body or headers.
`bundle exec rake` passes; the `auth/client-credentials-jwt` conformance scenario
passes 7/7 checks and the baseline check passes with its expected-failure entry removed.
## Breaking Changes
None. `client_secret:` becomes optional on `ClientCredentialsProvider` only
in the signature; it is still required for the secret-based methods, and all
existing construction paths behave unchanged.
## Motivation and Context
SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468, merged for the 2026-07-28 spec release) recommends
that authorization servers include the RFC 9207 `iss` parameter in authorization responses and requires MCP clients
to validate a present `iss` against the expected issuer before redeeming the authorization code.
This mitigates mix-up attacks in multi-IdP setups, where an attacker-controlled authorization server could otherwise capture
a code minted by an honest server.
This mirrors the TypeScript SDK's approach (typescript-sdk#2272, with typescript-sdk#1957 as the reference implementation),
adapted to the Ruby provider contract:
- `Provider`'s `callback_handler` may now return `[code, state, iss]` in addition to the existing `[code, state]`.
The 3-element form opts into validation; returning `iss = nil` asserts the caller inspected the authorization response
and found no `iss`.
- `Flow#run!` validates the issuer after the `state` check and before `exchange_authorization_code`,
implementing the RFC 9207 Section 2.4 rules: a present `iss` must equal the AS metadata `issuer` exactly
(the value `ensure_issuer_matches!` already pinned to the discovery URL); a nil `iss` from a 3-element callback is rejected
when the AS metadata advertises `authorization_response_iss_parameter_supported: true`; a legacy 2-element callback skips the check,
so every existing integration keeps working. On any violation the flow aborts with `AuthorizationError` before
the code reaches a token endpoint.
- The conformance client now returns the 3-element form, plumbing `iss` through from the redirect URL.
Resolves#386.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb`:
- a matching `iss` completes the flow and persists tokens
- a mismatched `iss` raises `AuthorizationError` and never contacts the token endpoint (`assert_not_requested` on the token POST)
- a nil `iss` with `authorization_response_iss_parameter_supported: true` raises without contacting the token endpoint (fail closed)
- a legacy 2-element callback completes even when the AS advertises `iss` support (backward compatibility)
- a nil `iss` without the advertisement completes
- a mismatched `iss` is rejected even without the advertisement (validate-when-present is unconditional)
`bundle exec rake` (tests, RuboCop, and conformance baseline with the 3-element conformance callback) passes.
## Breaking Changes
None. The 2-element `callback_handler` contract continues to work unchanged; issuer validation activates only
when a callback opts in by returning the 3-element form.
## Motivation and Context
MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate
the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost,
to prevent DNS rebinding attacks against locally bound MCP servers.
`StreamableHTTPTransport` previously validated neither `Host` nor `Origin`,
so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin,
invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m.
The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of
external middleware) and the Python SDK (`transport_security.py` disables protection by default)
both ship this off for backwards compatibility, a constraint they cannot escape after 1.0.
Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline:
- `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`,
compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`.
This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`.
A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one.
- `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port)
case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches
`Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected.
- `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers.
A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:
- a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed
- a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values
(`127.0.0.1`, `localhost`, `[::1]`) are allowed
- an upper-cased loopback `Host` is accepted (case-insensitive matching)
- a non-loopback `Host` listed in `allowed_hosts:` is allowed
- a request with no `Host` header, and one with no `Origin` header, are allowed
- a default-port `Origin` (`http://localhost`) matches `Host: localhost:80`
- opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected
- a `host:port` `allowed_hosts:` entry matches that port only
- `Host` validation also applies to GET requests
- `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through
## Breaking Changes
`StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default.
A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`,
or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients
are unaffected.
Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
## Motivation and Context
Resolves the `sse-retry` client conformance scenario (SEP-1699,
https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699).
Per SEP-1699, a server may close a request's SSE stream right after a priming event
(an event carrying an `id:`) without delivering the response, expecting the client
to treat the graceful close like a network failure: wait the server-specified
`retry:` interval, then reconnect with an HTTP GET carrying `Last-Event-ID`
so the server can replay the pending response on the resumed stream.
The Ruby client previously read the entire SSE body to EOF and parsed it afterward,
so it had no notion of per-event state (`id:`, `retry:`) or reconnection,
and the scenario failed all three checks (graceful reconnect, retry timing, `Last-Event-ID`).
`MCP::Client::HTTP#send_request` now consumes responses incrementally
via Faraday's `on_data` streaming callback, feeding SSE chunks to `event_stream_parser`
as they arrive while buffering plain JSON bodies as before. A new internal `SSEStream`
tracks the last received event id, the `retry:` reconnection delay,
and the awaited JSON-RPC response; once the response arrives, a held-open stream is
abandoned via an internal control-flow exception since servers may never close it.
When a stream closes gracefully after a priming event but before the response,
the client sleeps for the `retry:` interval (default 1000ms when the server sent none),
then issues a GET with `Accept: text/event-stream`, the session headers, and `Last-Event-ID`,
for up to 2 attempts. The defaults match the Python SDK's `DEFAULT_RECONNECTION_DELAY_MS` and
`MAX_RECONNECTION_ATTEMPTS` (`streamable_http.py`); the TypeScript SDK's `StreamableHTTPClientTransport`
(`streamableHttp.ts`) likewise lets the `retry:` field override its backoff and reconnects only when
a priming event was received and no response has arrived.
The public behavior of `send_request` is unchanged: same return values, same error mapping,
and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error
without reconnecting. Because the previous implementation read `response.body` and therefore worked with any Faraday adapter,
a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read from `response.body`
(adapters without `on_data` support, e.g. the Faraday test adapter) or from the buffered chunks
(Faraday < 2.1, which invokes `on_data` without `env`).
The conformance client gains an `sse-retry` branch that calls the harness's `test_reconnection` tool,
and the scenario is removed from the expected failures baseline.
## How Has This Been Tested?
New unit tests covering: reconnection with `Last-Event-ID` after a primed graceful close including the `retry:` wait,
the default 1000ms delay when `retry:` is absent, raising after the reconnection attempt cap with `Last-Event-ID`
advancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks
(JSON and SSE responses through the Faraday test adapter, which ignores `on_data`, plus buffered SSE chunks
when `on_data` receives no `env`)
## Breaking Changes
None. The `send_request` contract is unchanged; SSE bodies are now parsed incrementally instead of after EOF,
and reconnection only activates when the server opts in by sending a priming event.
## 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
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.