## 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-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
The MCP authorization specification and SEP-1046 allow confidential clients to
obtain tokens through the OAuth 2.1 `client_credentials` grant: a machine-to-machine flow
with no user, no browser redirect, and no authorization code. The Ruby SDK
previously supported only the interactive authorization-code flow, so it could not
authenticate against a server that requires `client_credentials`. This makes
the `auth/client-credentials-basic` conformance scenario pass and is removed from
the expected failures list.
Following the TypeScript SDK (`ClientCredentialsProvider`) and the Python SDK
(`ClientCredentialsOAuthProvider`), the grant gets its own provider class rather than
overloading the authorization-code provider. The change adds:
- `MCP::Client::OAuth::ClientCredentialsProvider`, a dedicated provider
for the grant. `client_id` and `client_secret` are mandatory
(the grant is for confidential clients), `token_endpoint_auth_method` is
`client_secret_basic` (default) or `client_secret_post` -- `none` is
rejected because an unauthenticated `client_credentials` request is
meaningless. It has no redirect arguments, so a credentials-only
client never supplies the redirect URI / handlers that grant has no use for.
- `MCP::Client::OAuth::StorageBackedProvider`, an internal module that
shares the token / client-information persistence between the two provider classes
so `Flow` can treat any provider uniformly.
- `Provider#authorization_flow` (`:authorization_code`) and
`ClientCredentialsProvider#authorization_flow` (`:client_credentials`).
`Flow#run!` dispatches on this rather than inspecting
`client_metadata[:grant_types]`, which is protocol metadata for
the authorization server, not an SDK control signal.
- `run_client_credentials!` shares the same PRM / AS metadata discovery
and Communication Security checks as the interactive flow, then
exchanges `grant_type=client_credentials` at the token endpoint via
the existing `post_to_token_endpoint` (which already applies
`client_secret_basic`). There is no PKCE, redirect, or `offline_access`
augmentation, because the grant does not issue a refresh token
(OAuth 2.1 Section 4.3.3).
- `conformance/client.rb` builds a `ClientCredentialsProvider` for
the `auth/client-credentials-*` scenarios, reusing the harness-injected
`client_id` / `client_secret`.
## How Has This Been Tested?
New `ClientCredentialsProvider` tests cover: credentials are stored as
client information; `authorization_flow` is `:client_credentials`;
`client_secret_post` is accepted; missing `client_id` / `client_secret`
and `token_endpoint_auth_method: "none"` are rejected; and the token helpers
delegate to storage.
New `Flow` tests cover: a `client_credentials` provider exchanges
`grant_type=client_credentials` with HTTP Basic auth, saves the token,
and never contacts the authorization or registration endpoint;
the requested scope is taken from PRM `scopes_supported`; and a standard
`Provider` runs the authorization-code grant regardless of what its
`client_metadata[:grant_types]` lists. A `Provider` test asserts
`authorization_flow` is `:authorization_code`.
Conformance: `auth/client-credentials-basic` now passes 7/7 with no failures.
## Breaking Changes
None. `ClientCredentialsProvider` is a new class; the existing `Provider` keeps
its required `redirect_uri` / `redirect_handler` / `callback_handler` arguments
and its behaviour is unchanged. Existing interactive clients are unaffected.
## Motivation and Context
The MCP authorization specification (2025-11-25 scope-selection-strategy)
and RFC 6750 Section 3.1 describe OAuth 2.0 step-up authentication:
the client already holds a valid access token, but the server returns a 403
with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
to demand a higher-privileged scope for a specific operation.
The client is expected to run a fresh authorization request with
the escalated scope and retry.
Until now the Ruby SDK only ran the OAuth flow on 401 responses.
A 403 surfaced unchanged as a `RequestHandlerError` and the operation
that needed the escalated scope failed permanently, even with a valid token
in storage. This lined up with the `auth/scope-step-up` conformance
scenario being listed in `conformance/expected_failures.yml`.
The change adds:
- `MCP::Client::HTTP#send_request` recognises a 403 carrying
an `insufficient_scope` Bearer challenge as a step-up signal,
re-runs a full authorization request with the scope from the challenge,
and retries the original request. Retries are capped at
one per `send_request` call so a server that repeatedly demands more
scope cannot loop the transport.
- `run_step_up_flow!` deliberately bypasses the refresh-token path that
`run_oauth_flow!` uses on 401. Refreshing would re-issue the same
scope set the existing token already has and the server already
rejected. Only a fresh `authorization_code` grant with the escalated
scope can succeed.
- A plain 403 with no `insufficient_scope` challenge continues to
surface as `RequestHandlerError` with `error_type: :forbidden`.
- `conformance/client.rb` invokes a tool for the `auth/scope-step-up`
scenario; the scenario's authorization middleware only requires
the escalated scope on `tools/call`, so the previous `tools/list`-only
client could never trigger the second authorization.
- `auth/scope-step-up` is removed from `conformance/expected_failures.yml`.
Resolves#383.
## How Has This Been Tested?
New `HTTPOAuthTest` cases cover: the 403 insufficient_scope path
re-authorizes with the escalated scope from the challenge and retries
the original request with the new bearer token; a plain 403 without
the `insufficient_scope` challenge is surfaced as
`error_type: :forbidden` with no OAuth side effects; repeated
`insufficient_scope` responses retry at most once and then surface the 403;
a provider with a saved `refresh_token` still goes through
the full authorization request rather than exchanging the refresh token,
because the existing scope set is precisely what was rejected.
Conformance: `auth/scope-step-up` now passes 22/22 with no warnings.
`bundle exec rake test`, `bundle exec rake rubocop`, and
`bundle exec rake conformance` are all green.
## Breaking Changes
None. Behaviour on 401 is unchanged. A 403 without the `insufficient_scope`
Bearer challenge still raises the same `RequestHandlerError` as before.
The new retry only fires when both the WWW-Authenticate header signals step-up
and the transport was constructed with an OAuth provider.
## Motivation and Context
SEP-2351 (modelcontextprotocol/modelcontextprotocol#2351, merged for the 2026-07-28 spec release)
makes explicit that MCP uses the default `oauth-authorization-server` well-known URI suffix from
RFC 8414 Section 3.1 and defines no application-specific suffix.
`MCP::Client::OAuth::Discovery.authorization_server_metadata_urls` already conforms:
it probes the RFC 8414 default suffix first (path-inserted form for path issuers) and
otherwise uses only the OpenID Connect Discovery `openid-configuration` suffix.
Neither the Python SDK nor the TypeScript SDK needed a behavior change for this SEP either;
their tracking issues (python-sdk#2799, typescript-sdk#2256) remain documentation-level.
This change documents the SEP-2351 guarantee in the discovery helper and locks the behavior
in with regression tests so a future refactor cannot silently introduce a non-default suffix.
Resolves#384.
## How Has This Been Tested?
New regression tests in `test/mcp/client/oauth/discovery_test.rb`:
- the first probed candidate for a root issuer is exactly `/.well-known/oauth-authorization-server`
- every generated candidate uses only the `oauth-authorization-server` or `openid-configuration` suffix
- the RFC 8414 path-inserted `oauth-authorization-server` candidate comes first for issuers with a path component
- an issuer URL with a trailing slash is treated as a root issuer
## Breaking Changes
None. Documentation and regression tests only; no behavior change.
## Motivation and Context
SEP-2207 describes how an OAuth client obtains refresh tokens: it
declares the `refresh_token` grant type in its client metadata and
requests the `offline_access` scope, but only when the authorization
server advertises `offline_access` in its metadata `scopes_supported`.
Requesting `offline_access` when the server does not list it is forbidden.
The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never
requested `offline_access`, so it could not obtain a refresh token from a server
that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs
and makes the `auth/offline-access-scope` conformance scenario pass
(previously it reported one warning, `sep-2207-client-metadata-grant-types`,
and was listed in the expected-failures baseline).
The change adds:
- `Flow#augment_scope_with_offline_access`, called from `run!` after
`resolve_scope`, appends `offline_access` to the requested scope when
both the client opted into refresh tokens (its registered
`grant_types` include `refresh_token`) and the authorization server
advertises `offline_access` in its metadata `scopes_supported`.
Already-present `offline_access` is not duplicated. Gating on the server
advertisement keeps the SDK from ever requesting the scope where it is not supported.
- `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection
of the SDK's refresh support, which clears the SEP-2207 grant-types warning.
- `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
New `Flow` tests cover: `offline_access` is requested when the server advertises it
and the client declared the `refresh_token` grant; `offline_access` is NOT requested
when the client did not declare the grant; `offline_access` is NOT requested
when the server does not advertise it (even with the grant declared);
and `offline_access` is not duplicated when it is already part of the resolved scope.
Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings,
and `auth/offline-access-not-supported` still passes 13/13 (no regression).
`bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance`
are all green.
## Breaking Changes
None. `offline_access` is appended only when the client's own `grant_types` opt
into refresh tokens and the authorization server advertises the scope, so existing clients
that do neither see no change in the scope they request.
## Motivation and Context
The MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00` define
Client ID Metadata Documents (CIMD): a client publishes its OAuth metadata at an HTTPS URL and uses that URL
as the OAuth `client_id` instead of going through Dynamic Client Registration. The Python and TypeScript SDKs
already implement this; the Ruby SDK now reaches parity.
The change adds:
- `MCP::Client::OAuth::Provider` accepts an optional `client_id_metadata_document_url:` keyword.
- A new `Discovery.client_id_metadata_document_url?` validator enforces structural requirements.
Spec-required: `https` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments
(including the percent-encoded form `%2E`). Stricter than the draft as an SDK policy: query strings
are also rejected, because different encodings of the same query would yield distinct `client_id` strings for
the same logical document. The loopback `http` carve-out used for discovery URLs does not extend to CIMD URLs,
since the value is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback.
Validation failures raise `Provider::InvalidClientIDMetadataDocumentURLError`.
- `Flow#ensure_client_registered` routes through CIMD only when the authorization server advertises
`client_id_metadata_document_supported` as JSON `boolean true`; truthy non-boolean values
(string `"true"`, `"false"`, etc.) fall back to DCR, matching the gating in the Python and TypeScript SDKs.
- The CIMD `client_id` is NOT persisted to storage. AS support is re-read on every flow so a server
that later drops CIMD support stops receiving the stale URL. Pre-registered `client_information`
continues to win over CIMD when both are available.
- `Flow#refresh!` reconstructs the CIMD `client_id` from live AS metadata when refreshing tokens obtained via CIMD.
When support has been withdrawn and no other identity is stored, it raises a clear `AuthorizationError` rather than
sending a `client_id` the authorization server no longer recognises.
- The Provider docstring and README OAuth section explain that DCR `client_metadata` and the JSON document served at
the CIMD URL are separate artifacts: DCR metadata MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `auth/basic-cimd` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
`Discovery` tests cover: https with path accepted; non-https rejected (http, ftp, file); root and empty path rejected;
fragment, query, and userinfo rejected; dot segments rejected including the percent-encoded form `%2E`; nil, empty,
and malformed inputs rejected.
`Provider` tests cover: the default `nil`; rejection of http URLs (loopback included); non-https schemes;
root and empty path; fragment; query; userinfo; and dot segments.
`Flow#run!` tests cover: skip DCR when the authorization server advertises CIMD and the provider has a URL;
fall back to DCR when the authorization server does not advertise support; fall back to DCR when the value is
the string `"false"` or `"true"`; pre-registered `client_information` wins over CIMD; the CIMD `client_id` is not
persisted to storage; a second flow against an authorization server that has dropped CIMD support recovers via DCR.
`Flow#refresh!` tests cover: refresh reconstructs the CIMD `client_id` without a stored `client_information`;
refresh raises when the authorization server no longer advertises CIMD support; stored `client_information`
takes precedence over the CIMD URL during refresh.
Conformance: `auth/basic-cimd` now passes 12/12 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`,
and `bundle exec rake conformance` are all green.
## Breaking Changes
None. The `client_id_metadata_document_url:` keyword is purely opt-in and defaults to `nil`.
When the keyword is omitted, the DCR flow is unchanged. Existing OAuth users see no behaviour change.
The CIMD path is only entered when both the user explicitly configures a URL and the authorization server
advertises support via `client_id_metadata_document_supported: true`.
## Motivation and Context
The MCP Authorization spec (2025-11-25) requires clients to support
Protected Resource Metadata discovery (RFC 9728), Authorization
Server Metadata discovery (RFC 8414), Dynamic Client Registration,
and OAuth 2.1 Authorization Code grant with PKCE (S256). The Ruby SDK
previously had no client-side OAuth support: the HTTP transport raised
`RequestHandlerError` on a 401 with no recovery, and 21 `auth/*` scenarios
were listed as known failures in `conformance/expected_failures.yml`.
This aligns the Ruby SDK with the Python and TypeScript SDKs,
both of which expose a pluggable OAuth client provider that drives
the full discovery + DCR + authorization + token exchange flow.
`MCP::Client::OAuth::Provider` mirrors that surface: callers supply
`client_metadata`, a `redirect_uri`, and pluggable `redirect_handler`
/ `callback_handler` hooks plus a token/client information storage.
`MCP::Client::OAuth::InMemoryStorage` is provided as a default.
`MCP::Client::HTTP` accepts a new optional `oauth:` keyword. When present,
the transport attaches `Authorization: Bearer` to every request,
and on a 401 parses `WWW-Authenticate` for the `resource_metadata` URL
and `scope` challenge, drives the discovery + DCR + authorization code flow
through the provider, and retries the failed request once with the acquired token.
Spec compliance details:
- The `WWW-Authenticate` parser handles multiple challenges
(e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`), extracting only
the parameters that belong to the Bearer challenge.
- The PRM `resource` value is required to cover the MCP server URL
(same scheme/host/port, with the PRM path as a prefix of the server path)
before the flow continues, preventing redirection of issued tokens to a different audience.
- Both authorization and token requests always include a `resource`
parameter (RFC 8707), defaulting to a canonicalized form of
the MCP server URL when PRM does not advertise one.
- `client_secret_basic`, `client_secret_post`, and `none` token endpoint
authentication methods are supported. When pre-registered client information
specifies no `token_endpoint_auth_method`, the transport defaults to
`client_secret_basic` per RFC 6749 §2.3.1 an dthe TS/Python SDK convention.
Pre-registered client credentials supplied via `Storage#save_client_information`
with either string or symbol keys are honored, skipping DCR.
- Scope resolution follows the spec order: the `WWW-Authenticate` challenge scope wins first,
then PRM `scopes_supported`, and `Provider#scope` is the last-resort fallback only
if neither is supplied (the provider-supplied scope must not pre-empt server-advertised scope).
- Token endpoint error responses (RFC 6749 §5.2) are parsed: a 4x with
`error: "invalid_grant"` raises `Flow::InvalidGrantError`,
so `MCP::Client::HTTP` can discard the stored refresh token only when
the AS truly says it is dead. Generic `AuthorizationError`
(5xx, network failures, other error codes) leaves the refresh token
intact, so a transient AS outage does not force interactive reauth
on the next attempt.
- The OAuth `state` parameter is compared via
`OpenSSL.fixed_length_secure_compare` (with a `bytesize` guard) to
prevent timing-based discovery of the expected value.
- The 401 retry guard is a request-scoped local variable, so an MCP server
that keeps returning 401 is surfaced as an error after one
retry rather than triggering an infinite re-authorization loop.
## How Has This Been Tested?
Unit tests cover PKCE generation, multi-challenge `WWW-Authenticate` parsing,
PRM and Authorization Server metadata URL builders, the full Flow orchestrator
(success path, state mismatch, missing PKCE support, skipping DCR for both string
and symbol-keyed preregistered client information, `client_secret_basic`
token endpoint authentication, PRM `resource` mismatch rejection, fallback to
the server URL when PRM omits `resource`, scope resolution preferring PRM
`scopes_supported` over `Provider#scope`, default `client_secret_basic` for
confidential clients without an explicit `token_endpoint_auth_method`,
and the new `InvalidGrantError` vs generic `AuthorizationError` split for
token endpoint rejections), and the HTTP transport behavior
(401 recovery + retry, no-provider fallthrough, no infinite loop on repeated 401,
no infinite loop on flow failure, refresh-token retention on transient 5xx from
the token endpoint).
Conformance: `bundle exec rake conformance` newly passes 16 `auth/*`
scenarios beyond the existing baseline, including
`auth/metadata-default`, `metadata-var{1,2,3}`,
`scope-from-www-authenticate`, `scope-from-scopes-supported`,
`scope-omitted-when-undefined`, `scope-retry-limit`,
`token-endpoint-auth-{basic,post,none}`, `pre-registration`,
`resource-mismatch`, and `offline-access-not-supported`. Remaining
`auth/*` scenarios stay in `expected_failures.yml`.
`bundle exec rake test rubocop` is clean.
## Breaking Changes
None. `MCP::Client::HTTP#initialize` accepts a new optional `oauth:`
keyword that defaults to `nil`, preserving existing behavior. When unset,
the transport raises `MCP::Client::RequestHandlerError` on a 401 exactly as before.
## Motivation and Context
This commit implements Phase 1 of 3 for #334.
The MCP specification requires an `initialize` request followed by
a `notifications/initialized` notification before any other interaction.
PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect`
to expose this handshake explicitly for the HTTP transport,
but stdio remained on its private `initialize_session` lazy-init path,
inconsistent with the Python SDK (`ClientSession.initialize()`) and
TypeScript SDK (`Client.connect(transport)`),
both of which require an explicit handshake call regardless of transport.
The implicit init path on the first `send_request` is
preserved as a non-breaking compatibility shim. Public API added on `Stdio`:
- `connect(client_info:, protocol_version:, capabilities:)` performs the
handshake, validates the negotiated `protocolVersion` against
`MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`,
caches the `InitializeResult`, and is idempotent.
- `connected?` reports handshake completion.
- `server_info` exposes the cached `InitializeResult`.
Documentation and examples are updated so new users learn the explicit pattern
from the start.
## How Has This Been Tested?
Fifteen regression tests cover: explicit handshake, `server_info` caching,
idempotence, custom keyword arguments, JSON-RPC error response, missing
or non-Hash `result`, unsupported protocol version with state rollback,
state rollback when the `notifications/initialized` write fails,
retry after a failed handshake, and the `connected?` / `server_info` lifecycle.
The pre-existing stdio tests pass without modification, verifying that
the implicit-init compatibility path still works.
## Breaking Changes
None for the typical "implicit init via `client.tools` / `client.call_tool`" usage.
Existing stdio clients keep working unchanged.
Three observable behavior changes for code that inspects transport state directly:
- `Client#connected?` for a never-initialized stdio client returns `false`
(previously fell through to a `true` default via the `respond_to?` fallback).
- `Client#server_info` for stdio returns the cached `InitializeResult`
after the handshake (previously always `nil` via the fallback).
- The implicit handshake path now validates the server's negotiated
protocol version, raising `RequestHandlerError` for unsupported versions.
Production servers that return one of the supported versions are unaffected;
this closes a pre-existing spec-conformance gap on stdio.
Per MCP spec, clients that no longer need a session SHOULD send an HTTP
DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
terminate it. Servers MAY respond with 405 Method Not Allowed when they
don't support client-initiated termination.
`MCP::Client::HTTP#close` sends the DELETE with session headers, clears
local session state regardless of outcome, and is a no-op when no
session has been established.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
Per the Streamable HTTP spec, if the server returns an Mcp-Session-Id
header during `initialize`, the client MUST include it on subsequent
requests. Capture the session ID and protocol version from the
`initialize` response and attach them automatically on subsequent
POSTs.
Per the Protocol Version Header section of the spec, the client MUST
also include `MCP-Protocol-Version` on all subsequent requests so the
server can respond based on the negotiated protocol version.
Map 404 responses to a new `SessionExpiredError` (a subclass of
`RequestHandlerError` for backward compatibility) and clear local
session state so callers can start a fresh session with a new
`initialize` request, as required by the spec.
- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per the Streamable HTTP spec, a server returns 202 Accepted with no
body as the ACK for a JSON-RPC notification or response. The client
previously errored on 202 because it fell through Content-Type
dispatch. Short-circuit to return nil before Content-Type handling so
fire-and-forget messages (e.g. notifications/initialized) no longer
raise.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Streamable HTTP spec allows servers to respond with either
`application/json` or `text/event-stream`. The client previously
rejected the latter. Add SSE parsing via the optional
`event_stream_parser` gem (users add it to their Gemfile when their
server uses SSE), dispatch on the response Content-Type, and scan the
event stream for the first JSON-RPC response message.
Document the new optional dependency in both the README's HTTP
Transport Layer section and docs/building-clients.md.
No other Streamable HTTP features (202 handling, session IDs,
`connect`, DELETE termination) are included here; those will land in
follow-up PRs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## Motivation and Context
The MCP specification defines cursor-based pagination for list operations that
may return large result sets:
https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination
Pagination allows servers to yield results in smaller chunks rather than all at once,
which is especially important when connecting to external services over the internet.
The Ruby SDK previously returned complete arrays for all list endpoints
(`tools/list`, `resources/list`, `prompts/list`, `resources/templates/list`)
without pagination support. This adds cursor-based pagination.
### Server-side
A new `MCP::Server::Pagination` module is introduced (mixed into `MCP::Server`)
that provides the `paginate` helper for slicing a collection by cursor and
a `cursor_from` helper that extracts the cursor from the request params
while rejecting non-Hash inputs with `-32602 Invalid params` per the specification.
`MCP::Server.new` accepts a new `page_size:` keyword argument.
When `nil` (the default), all items are returned in a single response,
preserving the existing behavior. When set to a positive integer,
list responses include a `nextCursor` field if more pages are available.
`Server#page_size=` is a validating setter that rejects anything other than
`nil` or a positive `Integer`, raising `ArgumentError` on invalid inputs.
The constructor routes through the setter, so both `Server.new(page_size: 0)`
and `server.page_size = -1` raise.
Cursors are string tokens carrying a zero-based offset, treated as opaque by
clients. Cursor inputs are validated to be strings per the MCP spec;
invalid cursors (non-string, non-numeric, negative, or out-of-range) raise
`RequestHandlerError` with error code `-32602 (Invalid params)` per the spec.
### Client-side: two complementary APIs
The SDK exposes two API shapes for listing, each serving a different use case:
1. **Single-page with cursor** (`client.list_tools(cursor:)`,
`list_resources`, `list_resource_templates`, `list_prompts`): each call issues
one JSON-RPC request and returns a result object (`MCP::Client::ListToolsResult` etc.)
carrying the page items and an optional `next_cursor`.
These methods match the single-page-with-cursor convention used by the Python SDK
(`session.list_tools(params=PaginatedRequestParams(cursor=...))`)
and the TypeScript SDK (`client.listTools({ cursor })`). The method name and
the `cursor` parameter name are identical across the three SDKs,
so pagination code translates directly between languages.
2. **Whole-collection** (`client.tools`, `client.resources`, `client.resource_templates`,
`client.prompts`): auto-iterates through all pages and returns a plain array of items,
guaranteeing the full collection regardless of the server's `page_size` setting.
The auto-pagination loop tracks previously seen cursors in a set and exits if the server
revisits any of them, guarding against both immediate repeats and multi-cursor cycles
(e.g. `A -> B -> A`). This mirrors Rust SDK's `list_all_*` convenience methods,
though the Ruby names are preserved from the pre-pagination 0.13.0 API for backward compatibility.
Future rename to `list_all_*` is left as a TODO in the source.
Result classes are `Struct`s named to mirror Python SDK's `ListToolsResult` /
`ListPromptsResult` / etc. to align naming with other SDKs. Each struct exposes
its page items (e.g. `result.tools`), an optional `next_cursor` for continuation,
and an optional `meta` field mirroring the MCP `Result` type's `_meta` response field
so servers that decorate list responses do not lose metadata through the client.
### Ruby-specific ergonomics
`Server.new(page_size:)` and the `Pagination` module are Ruby-specific.
The Python and TypeScript SDKs do not expose a built-in `page_size` helper;
developers there re-implement cursor slicing inside each handler.
In Ruby, setting `page_size:` on the server is enough for the SDK to handle
the slicing across the four built-in list endpoints.
### README
A Pagination section documents both the server-side `page_size:` option and
the client-side iteration patterns, including an explicit note that each `list_*` call
is a single JSON-RPC round trip whose response size depends on the server's `page_size`,
and that `client.tools` / `.resources` / `.resource_templates` / `.prompts` return
the complete collection when needed.
## How Has This Been Tested?
- Added tests for the `Pagination` module (`test/mcp/server/pagination_test.rb`),
server-side pagination (`test/mcp/server_test.rb`), and client-side pagination
(`test/mcp/client_test.rb`).
- Added and existing tests pass. `rake rubocop` is clean and `rake conformance` passes.
## Breaking Changes
None. This is a new feature addition. Existing code continues to work unchanged:
`page_size` defaults to `nil` on the server (returns all items, no `nextCursor`),
and the client's existing `tools` / `resources` / `resource_templates` / `prompts` methods
continue to return complete arrays by transparently iterating through pages.
## Motivation and Context
`MCP::Client::HTTP` builds its Faraday connection internally in a private `client` method
with no way to customize the middleware stack or adapter.
Users who need observability (request/response recording, failure logging) or
a different HTTP adapter must override a private method, coupling to an internal API.
This accepts an optional block in `MCP::Client::HTTP.new` that yields the Faraday builder
after default middleware is configured, allowing users to add custom middleware or swap
the HTTP adapter.
## How Has This Been Tested?
Added a test that verifies custom headers set via the block are included in requests.
All existing tests continue to pass.
## Breaking Changes
None. The block argument is optional, so existing code is unaffected.
Resolves#303
## Motivation and Context
The `MCP::Client` currently only supports HTTP transport (`MCP::Client::HTTP`).
To achieve parity with other MCP SDKs (Python, TypeScript, C#) which provide
stdio client transports for spawning and communicating with server subprocesses,
the Ruby SDK needs a stdio client transport.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#stdio
This enables building MCP clients that launch server processes and communicate
via stdin/stdout using newline-delimited JSON-RPC 2.0 messages.
## How Has This Been Tested?
Added tests for process spawning, protocol initialization handshake,
notification handling, error handling, and transport close lifecycle.
## Additional context
Enabled the `Lint/IncompatibleIoSelectWithFiberScheduler` cop in `.rubocop.yml`
to detect accidental use of `IO.select` instead of `IO#wait_readable`.
- Set Accept header to request JSON or SSE responses
- Validate response Content-Type and raise error for non-JSON responses
- Add Accept header to test fixtures
- Use explicit hash syntax for Ruby 2.7 compatibility
This commit implements optional JSON Schema validation for tool outputs according to the MCP specification:
- Add MCP::Tool::OutputSchema class mirroring InputSchema functionality
- Support output_schema in class definitions, Tool.define, and OutputSchema objects
- Include validate_result method for runtime validation
- Update MCP::Client::Tool to track output schemas
- Add comprehensive test coverage with 249 passing tests
- Update README with dedicated Tool Output Schemas section
- Follow MCP spec requirements for server/client validation