## Motivation and Context
The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990,
accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization:
the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via
RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with
the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`.
The `auth/cross-app-access-complete-flow` conformance scenario exercises the complete flow and previously failed
its two core checks (the Ruby client ran the authorization-code flow instead).
This mirrors the merged TypeScript SDK implementation (`requestJwtAuthorizationGrant` / `CrossAppAccessProvider` in
typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design:
- New `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint
(`subject_token_type` id_token, `requested_token_type` id-jag, `audience` = the MCP authorization server's issuer,
`resource` = the canonical MCP server URL) and validates that the response's `issued_token_type` is an ID-JAG
before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK.
- New `MCP::Client::OAuth::CrossAppAccessProvider` declares `authorization_flow :jwt_bearer` and takes the assertion
from a callable (`call(audience:, resource:)`), so the common `IDJAGTokenExchange` case, enterprise secret stores,
and tests all plug in the same way (the TypeScript provider's assertion callback works identically).
The MCP AS credentials are stored as `client_secret_basic` client information.
- `Flow` dispatches `:jwt_bearer` alongside `:client_credentials`: discovery, issuer validation, and endpoint security checks
are shared with `run!`, then `run_jwt_bearer!` requests the assertion with the validated issuer as the audience and posts
the `jwt-bearer` grant through the existing `post_to_token_endpoint`.
- The conformance client builds a `CrossAppAccessProvider` when the harness injects `idp_id_token` (exchanging it at
`idp_token_endpoint` with `idp_client_id`, like the TypeScript and Python conformance clients),
and `auth/cross-app-access-complete-flow` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
- New `test/mcp/client/oauth/id_jag_token_exchange_test.rb` asserts the exact RFC 8693 form parameters on the wire
and the response validation: a non-ID-JAG `issued_token_type` is rejected, as are a missing `access_token`,
non-2xx responses, unparseable bodies, and non-object JSON.
- New `test/mcp/client/oauth/cross_app_access_provider_test.rb` covers the provider surface:
stored `client_secret_basic` credentials, the `:jwt_bearer` flow declaration, audience/resource passthrough to
the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider),
and storage delegation.
- New tests in `test/mcp/client/oauth/flow_test.rb` run the full grant against WebMock stubs: the token request carries
`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the assertion, the RFC 8707 `resource`, and HTTP Basic credentials,
with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as `audience`
and the canonical server URL as `resource`; an empty assertion aborts before the token endpoint is contacted.
## Breaking Changes
None. `IDJAGTokenExchange` and `CrossAppAccessProvider` are new, and the `Flow` dispatch only activates for providers
that declare `authorization_flow :jwt_bearer`.
## 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
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
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.