Compare commits

..
24 changed files with 216 additions and 2727 deletions

View file

@ -19,6 +19,9 @@ gem "activesupport"
gem "debug" if RUBY_VERSION >= "3.1"
# Avoid i18n 1.15.0, which breaks on Ruby 3.1 (ruby-i18n/i18n#735).
gem "i18n", "!= 1.15.0"
# FIXME: Drop this once a json release includes https://github.com/ruby/json/pull/1072. json 3.0.0 and 3.0.1
# forward arguments after a leading parameter, syntax Ruby 2.7.3 was the first to parse, while allowing Ruby 2.7.0.
gem "json", "< 3" if RUBY_VERSION < "2.7.3"
gem "rake", "~> 13.0"
gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0"
gem "yard", "~> 0.9"

View file

@ -37,13 +37,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates),
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`,
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
`ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead;
their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again.
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
@ -99,8 +95,6 @@ Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.
See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client).
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
@ -118,14 +112,6 @@ Optional keyword arguments:
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
Defaults to `nil`, which adds nothing. The authorization request is not affected.
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
when the provider is built, and the accepted Hash is copied and frozen.
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
the flow checks the returned value by the same rules and raises the same error before the token request is sent.
{: .warning }
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
@ -173,33 +159,11 @@ provider = MCP::Client::OAuth::Provider.new(
)
```
### Token Endpoint Errors
When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and
the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2).
For example:
```text
Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods
```
The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string
diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers.
An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token
recovery behavior is unchanged.
Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when
truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed.
The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors.
Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging
and redaction policy before persisting them or displaying them to users.
### Client Credentials Grant
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant is not expected to issue a refresh token (RFC 6749 Section 4.4.3);
a refresh token the authorization server issues anyway is used on the next `401`.
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
@ -207,7 +171,6 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
@ -223,15 +186,13 @@ Keyword arguments:
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
### Cross-App Access (JWT Bearer) Grant
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again.
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
@ -262,39 +223,7 @@ Keyword arguments:
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
### Customizing the OAuth HTTP Client
The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery,
dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own,
not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server.
To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider:
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder },
)
```
The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL,
and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection.
It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently;
today it runs once per authorization attempt, but that is not a promise.
A few constraints follow from the checks described below:
- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send
to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError`
before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)`
that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off.
- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off.
- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request
and to turn a token endpoint error into `Flow::InvalidGrantError`.
- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives.
- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens,
and the access tokens in token responses; redact them before they reach a log.
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
### Communication Security
@ -326,11 +255,8 @@ The range check compares IP literals and does not resolve hostnames, so it canno
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request
goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)`
replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against
the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused.
A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself.
If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.

View file

@ -17,8 +17,7 @@ This class supports:
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
when the server serves it; see [Lifecycle](/client/lifecycle/)
- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery)
- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings;
see [Answering Server Pings](/client/ping/#answering-server-pings)
- Liveness check via the `ping` method (`MCP::Client#ping`)
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
- Resource listing via the `resources/list` method (`MCP::Client#resources`)

View file

@ -32,17 +32,13 @@ propagate as exceptions raised by the transport layer.
## Answering Server Pings
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with
the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open
(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings.
the empty result - no handler is needed. Registering `transport.on_server_request("ping")` on `MCP::Client::HTTP` replaces
the automatic answer.
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when
the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is
dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:`
on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits.
To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces
the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`,
as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page.
## Server Side
How servers answer `ping` requests and ping the client themselves is documented on

View file

@ -59,14 +59,6 @@ The stdio transport automatically handles:
- Spawning the server process with `Open3.popen3`
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
- JSON-RPC 2.0 message framing over newline-delimited JSON
- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings)
{: .note }
> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create`
> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer
> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route,
> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation`
> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/).
## HTTP Transport Layer
@ -248,9 +240,6 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
end
```
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
{: .note }
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
> the SSE streaming callback of another response, re-entering the connection on the same thread.

View file

@ -48,5 +48,3 @@ Server-to-client requests are bounded by a timeout on the Streamable HTTP transp
## Client Side
Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page.
The Ruby client answers the pings sent with `ServerSession#ping` automatically;
see [Answering Server Pings](/client/ping/#answering-server-pings).

View file

@ -49,13 +49,6 @@ The first SSE event on the stream is the acknowledgement:
}
```
## Delivery Semantics
A stream delivers only the changes that happen after its acknowledgement; it is not a replay log.
Nothing is queued while no stream is connected, and a dropped stream is gone together with anything it missed.
Clients fetch the current state after subscribing and treat each notification as a cue to refetch,
rather than as a payload.
## Transport Support
The stream is served on the Streamable HTTP modern path; stdio answers `-32601`.

View file

@ -26,7 +26,7 @@ module MCP
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
# the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
# the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`.
def on_data
proc do |chunk, _received_bytes, _env|
@buffer << chunk

View file

@ -37,17 +37,11 @@ module MCP
# also take the algorithm as an explicit option).
# - `scope` - String of space-separated scopes to request when the server's
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
# and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
# The `client_id` / `client_secret` are written into it so the token exchange reads
# them through the same path as a pre-registered authorization-code client.
# - `token_request_params` - Hash of String keys and values added to every
# token request this provider makes, for parameters the authorization
# server requires beyond the grant itself (Auth0's `audience`, for example).
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
# its own requests; see `StorageBackedProvider#http_client_customizer`.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
# `client_information`, and `save_client_information(info)`. Defaults to
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
# into it so the token exchange reads them through the same path as
# a pre-registered authorization-code client.
class ClientCredentialsProvider
include StorageBackedProvider
@ -67,9 +61,7 @@ module MCP
signing_algorithm: nil,
scope: nil,
storage: nil,
authorization_request_validator: nil,
token_request_params: nil,
http_client_customizer: nil
authorization_request_validator: nil
)
if blank?(client_id)
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@ -107,16 +99,12 @@ module MCP
client_information["client_secret"] = client_secret
end
http_client_customizer = validated_http_client_customizer(http_client_customizer)
@client_id = client_id
@private_key = private_key
@signing_algorithm = signing_algorithm
@scope = scope
@storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@http_client_customizer = http_client_customizer
@storage.save_client_information(client_information)
end

View file

@ -25,12 +25,6 @@ module MCP
# the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
# Defaults to an `InMemoryStorage`.
# - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
# See `StorageBackedProvider#token_request_params`.
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests;
# see `StorageBackedProvider#http_client_customizer`.
#
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
class CrossAppAccessProvider
@ -41,16 +35,7 @@ module MCP
attr_reader :scope, :storage
def initialize(
client_id:,
client_secret:,
assertion_provider:,
scope: nil,
storage: nil,
authorization_request_validator: nil,
token_request_params: nil,
http_client_customizer: nil
)
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
if blank?(client_id)
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
end
@ -63,14 +48,10 @@ module MCP
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
end
http_client_customizer = validated_http_client_customizer(http_client_customizer)
@assertion_provider = assertion_provider
@scope = scope
@storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@http_client_customizer = http_client_customizer
@storage.save_client_information(
"client_id" => client_id,
"client_secret" => client_secret,

View file

@ -336,29 +336,6 @@ module MCP
uri.to_s
end
# Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting
# a URL next to the request that failed, where it must still match the server's access log; `URI#to_s`
# still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path`
# it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters
# for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry
# the very credentials being dropped.
def redact_url(url)
uri = URI.parse(url.to_s)
uri.fragment = nil
uri.query = nil
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
# so clear the components individually.
if uri.respond_to?(:user) && (uri.user || uri.password)
uri.user = nil
uri.password = nil
end
uri.to_s
rescue URI::Error
"[unparseable URL]"
end
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
# also advertises a query string, the server's query MUST be identical to it (otherwise

View file

@ -14,38 +14,7 @@ module MCP
# `Provider`; this class consumes a Provider plus signal data extracted from
# the failing response (resource_metadata URL, scope challenge).
class Flow
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
METADATA_URL_MAX_LENGTH = 2048
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
# so a provider naming one of these is refused rather than left believing its value was sent.
RESERVED_TOKEN_REQUEST_PARAMS = [
"grant_type",
"client_id",
"client_secret",
"client_assertion",
"client_assertion_type",
"scope",
"resource",
"code",
"code_verifier",
"redirect_uri",
"refresh_token",
"assertion",
].freeze
class AuthorizationError < StandardError
attr_reader :http_status, :error, :error_description
def initialize(message = nil, http_status: nil, error: nil, error_description: nil)
super(message)
@http_status = http_status
@error = error
@error_description = error_description
end
end
class AuthorizationError < StandardError; end
# Raised specifically when the token endpoint rejects a grant with
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
@ -59,136 +28,6 @@ module MCP
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
class AuthorizationRefusedError < AuthorizationError; end
# Raised by metadata discovery when every candidate URL answered that nothing usable is published there
# (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object).
# The only discovery failure that may select the legacy 2025-03-26 path.
class MetadataNotPublishedError < AuthorizationError; end
# Raised by metadata discovery when the answer says nothing about what is published: the request failed to
# reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move
# the flow to a different authorization server because of a transient failure, so it is surfaced instead,
# as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both.
class MetadataUnreachableError < AuthorizationError; end
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
# a failed refresh as a reason to run the interactive flow.
class InvalidTokenRequestParamsError < ArgumentError; end
# Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer`
# would send a request to an origin other than the one the flow validated, or has dropped the record of
# the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake,
# and deliberately outside `AuthorizationError`, which discovery treats as "nothing published"
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
class DestinationMismatchError < ArgumentError; end
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
# The guard covers what happens to a request after that record; middleware inserted ahead of it with
# `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard.
# The record lives on the environment, not in `env.request.context`: that slot belongs to the application,
# which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen
# on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after
# a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with
# the redirected URL.
class RequestedURLStamp
KEY = :mcp_oauth_requested_url
def initialize(app)
@app = app
end
def call(env)
env[KEY] ||= env.url.to_s
@app.call(env)
end
end
# Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added
# middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked
# for is refused before it reaches the adapter, since every destination check ran against the URL as
# written; a same-origin change stays with the server those checks admitted. The record survives
# the `env.dup` that redirect-following middleware performs, and a request that arrives without it is
# refused as well, so a middleware that rebuilds the environment fails closed rather than open.
# The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows
# a redirect itself only within the origin; this flow follows none.
class RequestedOriginGuard
def initialize(app)
@app = app
end
def call(env)
requested = env[RequestedURLStamp::KEY]
unless requested
raise DestinationMismatchError, <<~MESSAGE
Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \
the URL the flow asked for; middleware that rebuilds the request environment is refused.
MESSAGE
end
unless Discovery.same_origin?(env.url.to_s, requested)
raise DestinationMismatchError, <<~MESSAGE
Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \
#{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \
middleware that follows redirects or rewrites URLs is refused.
MESSAGE
end
@app.call(env)
end
end
private_constant :RequestedURLStamp, :RequestedOriginGuard
class << self
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
# so the same problem reads the same wherever it surfaces.
def token_request_params_problem(params)
return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)
# Two equal keys are two entries here, which would be sent twice from a provider method
# or silently collapse into one when the constructors copy the Hash.
return "must not compare keys by identity." if params.compare_by_identity?
params.each do |key, value|
return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
end
nil
end
# Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`,
# then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`),
# then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request
# after the stamp recorded it. Every request on the connection passes through both, so a caller using
# it directly is held to the same origin rule.
#
# Deliberately built without redirect-following middleware. Every destination check in this class runs
# against the URL as written, before the request goes out, so a connection that transparently followed
# a `3xx` would let a server reach a host the checks just refused. The guard turns following at
# the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must
# not enable it.
#
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
# after the check.
def build_http_client(customizer = nil)
require "faraday"
Faraday.new do |faraday|
faraday.headers["Accept"] = "application/json"
faraday.use(RequestedURLStamp)
customizer&.call(faraday)
faraday.use(RequestedOriginGuard)
end
end
end
def initialize(provider:, http_client_factory: nil)
@provider = provider
@http_client_factory = http_client_factory || -> { default_http_client }
@ -236,7 +75,7 @@ module MCP
ensure_pkce_supported!(as_metadata)
effective_scope = resolve_scope(scope: scope, prm: prm)
effective_scope = resolve_scope(scope: scope, prm: prm || {})
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
@ -287,7 +126,7 @@ module MCP
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
# and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3).
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
@ -380,22 +219,19 @@ module MCP
# checks before talking to it.
#
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
# when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer,
# or when the token endpoint refuses the refresh request.
# https://www.rfc-editor.org/rfc/rfc6749#section-6
def refresh!(server_url:, resource_metadata_url: nil)
refresh_token = read_token("refresh_token")
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
ensure_refresh_token_issuer_recorded!
stored_client_info = @provider.client_information
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
# A CIMD-configured provider stores no `client_information` on purpose
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
have_cimd_url = !provider_client_id_metadata_document_url.nil?
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
unless have_stored_client_info || have_cimd_url
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
@ -431,7 +267,7 @@ module MCP
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
stored_client_info
elsif as_metadata["client_id_metadata_document_supported"] == true
{ "client_id" => provider_client_id_metadata_document_url }
{ "client_id" => @provider.client_id_metadata_document_url }
else
raise AuthorizationError,
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
@ -483,12 +319,7 @@ module MCP
#
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
# Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`,
# a redirect that was not followed, or a body that is not a JSON object) selects the legacy path.
# A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes,
# so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`),
# as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors.
# A body over the response cap is refused outright and never reaches the fallback either.
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
def locate_authorization_server(server_url:, resource_metadata_url:)
prm = begin
@ -496,7 +327,7 @@ module MCP
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
rescue MetadataNotPublishedError
rescue AuthorizationError
nil
end
@ -518,26 +349,16 @@ module MCP
# Fetches and validates the authorization server's RFC 8414 metadata.
#
# The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths.
# On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as
# the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs
# accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer`
# would otherwise become the identity tokens and client information are bound to, assertions are minted for,
# and the validator is shown, so a server could claim another authorization server and unlock the credentials
# bound to it.
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
# On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
# When even the metadata document is absent, the legacy spec's default endpoints are used.
def authorization_server_metadata(authorization_server:, legacy:, server_url:)
metadata = if legacy
fetched = begin
begin
fetch_authorization_server_metadata(issuer_url: authorization_server)
rescue AuthorizationError
nil
end
if fetched
ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
fetched
else
default_legacy_metadata(authorization_server)
end
else
@ -582,14 +403,6 @@ module MCP
fetch_metadata_json(urls, label: "authorization server metadata")
end
# The legacy authorization base is an origin, which a document may render as `https://host/`;
# both name the same server, and nothing else does.
def ensure_legacy_issuer_matches!(expected:, returned:)
return if returned == "#{expected}/"
ensure_issuer_matches!(expected: expected, returned: returned)
end
# Reads `authorization_servers` from a PRM document and returns
# the first entry, raising `AuthorizationError` for any of the malformed
# shapes a non-compliant server could emit (missing field, non-Array
@ -617,63 +430,44 @@ module MCP
first
end
# Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
# the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
# be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
# or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing
# about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises
# `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that
# was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`.
# A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`,
# before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment
# and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against
# a server's access log, and with exception text bounded, since the message lands in every log destination
# the error passes through.
# Walks candidate metadata URLs and returns the parsed JSON body of
# the first 2xx response. Raises `AuthorizationError` for transport
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
# so callers do not have to handle raw Faraday/JSON exceptions.
def fetch_metadata_json(urls, label:)
failures = []
inconclusive = false
last_error = nil
urls.each do |url|
response = begin
http_get(url)
rescue Faraday::Error => e
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
inconclusive = true
last_error = "GET #{url} raised #{e.class}: #{e.message}"
next
end
unless response.status >= 200 && response.status < 300
failures << "GET #{reported_url(url)} returned #{response.status}"
inconclusive = true if response.status >= 500 || response.status == 429
next
if response.status >= 200 && response.status < 300
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
end
# Even valid JSON can be the wrong shape (a top-level array,
# a bare `null`, a string, ...). The discovery callers index by
# name (`prm["authorization_servers"]`, etc.), so anything that
# is not a Hash would raise `TypeError` / `NoMethodError`
# downstream. Surface that as `AuthorizationError` instead so
# callers see a single, documented error type.
unless parsed.is_a?(Hash)
raise AuthorizationError,
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
end
return parsed
end
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name
failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}"
next
end
# Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...).
# The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that
# is not a Hash would raise `TypeError` / `NoMethodError` downstream.
unless parsed.is_a?(Hash)
failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})"
next
end
return parsed
end
message = "Failed to fetch #{label}: #{failures.join("; ")}."
if inconclusive
raise MetadataUnreachableError, message
else
raise MetadataNotPublishedError, message
last_error = "GET #{url} returned #{response.status}"
end
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
end
def ensure_pkce_supported!(as_metadata)
@ -834,7 +628,7 @@ module MCP
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
# and the current `provider.client_id_metadata_document_url`.
cimd_url = provider_client_id_metadata_document_url
cimd_url = @provider.client_id_metadata_document_url
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
return { "client_id" => cimd_url }
end
@ -964,18 +758,6 @@ module MCP
MESSAGE
end
# `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`).
# The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new,
# and a refresh asks no validator, so a token without an `issuer` would be presented to whatever
# authorization server discovery names now. Refusing it sends the transport back through the grant,
# which does ask.
def ensure_refresh_token_issuer_recorded!
return if provider_authorization_flow == :authorization_code
return unless read_token("issuer").nil?
raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required."
end
def ensure_refreshable_client_information!(client_info, as_metadata:)
stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil?
@ -1104,8 +886,7 @@ module MCP
def resolve_scope(scope:, prm:)
return scope if scope && !scope.empty?
# `prm` is nil on the legacy path, where nothing advertises scopes.
supported = prm && prm["scopes_supported"]
supported = prm["scopes_supported"]
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
return @provider.scope if @provider.scope && !@provider.scope.empty?
@ -1167,31 +948,6 @@ module MCP
@provider.authorization_flow
end
# Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
# Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
# or one returning `nil`, adds nothing.
# A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
def provider_token_request_params
return {} unless @provider.respond_to?(:token_request_params)
params = @provider.token_request_params
return {} if params.nil?
problem = self.class.token_request_params_problem(params)
raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem
params
end
# The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader
# (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves
# every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`.
def provider_client_id_metadata_document_url
return unless @provider.respond_to?(:client_id_metadata_document_url)
@provider.client_id_metadata_document_url
end
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint
@ -1241,12 +997,9 @@ module MCP
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
end
# Submits a form-encoded token request using the authentication method
# stored in `client_information`. The method determines whether client
# credentials belong in the form body, a Basic header, or a JWT assertion.
# A provider's `token_request_params` go underneath the flow's own parameters,
# which therefore win, and are refused before the request is sent when they name
# a reserved parameter or are not a Hash of Strings.
# Submits a form-encoded request to the token endpoint, applying
# the client authentication method advertised in `client_information` and
# adding `client_id` (and `client_secret` when not using HTTP Basic).
def post_to_token_endpoint(as_metadata:, client_info:, form:)
client_id = client_info_required_value(client_info, "client_id")
unless client_id
@ -1256,15 +1009,13 @@ module MCP
client_secret = client_info_required_value(client_info, "client_secret")
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
form = provider_token_request_params.merge(form)
# Apply one client authentication method per request (RFC 6749 Section 2.3).
headers = {}
form = case token_endpoint_auth_method
when "private_key_jwt"
# The assertion identifies the client through its `iss` and `sub`
# claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2).
# Use the issuer already checked by `ensure_issuer_matches!` as the audience.
form = if token_endpoint_auth_method == "private_key_jwt"
# RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
# the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
# The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
# omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
# The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
unless @provider.respond_to?(:client_assertion)
raise AuthorizationError,
"token_endpoint_auth_method is private_key_jwt but the provider does not " \
@ -1275,24 +1026,22 @@ module MCP
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
)
when "client_secret_post"
# Send the client ID and available secret in the form body.
body = form.merge("client_id" => client_id)
body["client_secret"] = client_secret if client_secret
body
else
if client_secret && token_endpoint_auth_method != "none"
# Basic is also the fallback when a secret is present but no method
# is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1);
# omit it because some servers treat it alongside Basic as a second
# authentication method and reject the request with `invalid_request`.
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
form
form.merge("client_id" => client_id)
end
headers = {}
if client_secret
case token_endpoint_auth_method
when "client_secret_post"
form["client_secret"] = client_secret
when "none"
# Public client; no credential.
else
# With `none` or no secret, identify the client using `client_id`.
# This is required for unauthenticated authorization-code exchanges
# (RFC 6749 Section 3.2.1).
form.merge("client_id" => client_id)
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
# both Python and TypeScript SDKs default here when
# the authentication method is not explicitly stored.
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
end
end
@ -1310,7 +1059,11 @@ module MCP
end
if response.status < 200 || response.status >= 300
raise token_endpoint_error(response)
if token_endpoint_error_code(response) == "invalid_grant"
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
end
raise AuthorizationError, "Token endpoint returned status #{response.status}."
end
parsed = begin
@ -1331,41 +1084,17 @@ module MCP
parsed
end
# Surface only RFC 6749 §5.2 diagnostic fields, never the raw response,
# which may contain tokens or other credentials. Classify the original
# code so sanitization cannot turn malformed input into invalid_grant.
def token_endpoint_error(response)
message = "Token endpoint returned status #{response.status}."
error_class = AuthorizationError
parsed = JSON.parse(response_body_string(response))
parsed = {} unless parsed.is_a?(Hash)
# Extracts the `error` code from an RFC 6749 §5.2 error response body
# when one is parseable. Returns nil on any parse failure or when
# the body is not JSON.
def token_endpoint_error_code(response)
body = response_body_string(response).to_s
return if body.empty?
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
message += " #{[error, description].compact.join(": ")}" if error || description
error_class.new(message, http_status: response.status, error: error, error_description: description)
rescue StandardError
# Diagnostics must not mask the endpoint failure or change refresh recovery.
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
end
def bounded_diagnostic(value, limit:)
return unless value.is_a?(String)
# RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields.
# Replace other characters to keep text received off the network on one log line.
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
return if value.empty?
value.length > limit ? "#{value[0, limit - 3]}..." : value
end
# A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot
# grow the message without limit.
def reported_url(url)
bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH)
parsed = JSON.parse(body)
parsed["error"] if parsed.is_a?(Hash)
rescue JSON::ParserError
nil
end
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
@ -1433,18 +1162,22 @@ module MCP
@http_client ||= @http_client_factory.call
end
# A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
# `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
# `build_http_client`; `bounded_request` caps its responses all the same.
# Deliberately built without redirect-following middleware. Every destination check in
# this class runs against the URL as written, before the request goes out, so a connection
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
# and the guards above only cover the first hop.
#
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
# after the check.
def default_http_client
self.class.build_http_client(provider_http_client_customizer)
end
require "faraday"
# `nil` for a provider that predates the hook or leaves it unset.
def provider_http_client_customizer
return unless @provider.respond_to?(:http_client_customizer)
@provider.http_client_customizer
Faraday.new do |faraday|
faraday.headers["Accept"] = "application/json"
end
end
def response_body_string(response)

View file

@ -95,7 +95,7 @@ module MCP
assertion
end
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`:
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
# onto compressed bytes.
def default_http_client

View file

@ -48,13 +48,6 @@ module MCP
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
# DCR `client_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`.
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
# raises `Flow::InvalidTokenRequestParamsError`.
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
# its own requests; see `StorageBackedProvider#http_client_customizer`.
class Provider
include StorageBackedProvider
@ -94,9 +87,7 @@ module MCP
scope: nil,
storage: nil,
client_id_metadata_document_url: nil,
authorization_request_validator: nil,
token_request_params: nil,
http_client_customizer: nil
authorization_request_validator: nil
)
unless Discovery.secure_url?(redirect_uri)
raise InsecureRedirectURIError,
@ -118,8 +109,6 @@ module MCP
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
end
http_client_customizer = validated_http_client_customizer(http_client_customizer)
@client_metadata = client_metadata
@redirect_uri = redirect_uri
@redirect_handler = redirect_handler
@ -128,8 +117,6 @@ module MCP
@storage = storage || InMemoryStorage.new
@client_id_metadata_document_url = client_id_metadata_document_url
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@http_client_customizer = http_client_customizer
end
# Identifies the OAuth flow this provider drives.

View file

@ -4,11 +4,11 @@ module MCP
class Client
module OAuth
# Shared token/credential persistence for the OAuth provider classes
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
# The grants differ in how they authenticate, but all read and write the same two pieces of state
# through a `storage` object: the token response and the client information. This module supplies
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
# for the client_credentials flow). The two grants differ in how they authenticate,
# but both read and write the same two pieces of state through a `storage` object:
# the token response and the client information. This module supplies that delegation
# so the `Flow` orchestrator can treat any provider uniformly.
#
# Including classes must set `@storage` to an object responding to `tokens`,
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
@ -21,20 +21,6 @@ module MCP
# MCP SDK has today.
attr_reader :authorization_request_validator
# Optional Hash of String keys and values added to every token request the provider makes, for parameters
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
# with `Flow::InvalidTokenRequestParamsError`.
attr_reader :token_request_params
# Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests,
# after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can
# add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default)
# keeps the default connection. The transport's own connection and customizer block never serve these requests:
# they are bound to the MCP server URL and carry headers meant for that server.
attr_reader :http_client_customizer
def access_token
tokens&.dig("access_token") || tokens&.dig(:access_token)
end
@ -58,29 +44,6 @@ module MCP
def clear_tokens!
@storage.save_tokens(nil)
end
private
# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
# because `Hash` copies and freezes only keys whose class is exactly `String`.
def frozen_token_request_params(params)
return if params.nil?
problem = Flow.token_request_params_problem(params)
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
end
# Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed
# in place of a callable fails at construction rather than at the first `401`.
def validated_http_client_customizer(customizer)
return customizer if customizer.nil? || customizer.respond_to?(:call)
raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})."
end
end
end
end

View file

@ -1,35 +1,13 @@
# frozen_string_literal: true
module MCP
# An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type:
# https://modelcontextprotocol.io/specification/2026-07-28/basic#icons
#
# Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs,
# so an icon that would serialize into something a client rejects fails here instead. What a value means
# is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
# is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
# (the specification expects `WxH` or `"any"`).
class Icon
SUPPORTED_THEMES = ["light", "dark"].freeze
attr_reader :mime_type, :sizes, :src, :theme
def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
unless src.is_a?(String) && !src.empty?
raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})."
end
unless mime_type.nil? || mime_type.is_a?(String)
raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})."
end
if (problem = sizes_problem(sizes))
raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})."
end
unless theme.nil? || SUPPORTED_THEMES.include?(theme)
raise ArgumentError, 'The value of theme must specify "light" or "dark".'
end
def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil)
raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme)
@mime_type = mime_type
@sizes = sizes
@ -40,16 +18,5 @@ module MCP
def to_h
{ mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact
end
private
def sizes_problem(sizes)
return if sizes.nil?
return "got #{sizes.class}" unless sizes.is_a?(Array)
index = sizes.index { |size| !size.is_a?(String) }
"got #{sizes[index].class} inside the Array" if index
end
end
end

View file

@ -149,7 +149,7 @@ module MCP
raise ArgumentError, "Tool names should be between 1 and 128 characters in length (inclusive)."
end
unless tool_name.match?(/\A[A-Za-z\d_\-.]+\z/)
unless tool_name.match?(/\A[A-Za-z\d_\-\.]+\z/)
raise ArgumentError, <<~MESSAGE
Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.).
MESSAGE

View file

@ -7,23 +7,6 @@ module MCP
class Client
module OAuth
class ClientCredentialsProviderTest < Minitest::Test
# Every parameter the flow sets on a token request, spelled out rather than read from
# `Flow::RESERVED_TOKEN_REQUEST_PARAMS`, so a key dropped from that constant fails here.
RESERVED_TOKEN_REQUEST_PARAMS = [
"grant_type",
"client_id",
"client_secret",
"client_assertion",
"client_assertion_type",
"scope",
"resource",
"code",
"code_verifier",
"redirect_uri",
"refresh_token",
"assertion",
].freeze
def test_initialize_stores_credentials_as_client_information
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
@ -49,25 +32,6 @@ module MCP
assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"])
end
def test_initialize_keeps_the_http_client_customizer
customizer = ->(_faraday) {}
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", http_client_customizer: customizer)
assert_same(customizer, provider.http_client_customizer)
assert_nil(ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret").http_client_customizer)
end
def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials
storage = InMemoryStorage.new
error = assert_raises(ArgumentError) do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", storage: storage, http_client_customizer: "recorder")
end
assert_equal("http_client_customizer must respond to call (got String).", error.message)
assert_nil(storage.client_information)
end
def test_initialize_rejects_missing_client_id
["", " ", nil].each do |value|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
@ -196,90 +160,6 @@ module MCP
assert_equal("cc-client", claims["sub"])
assert_equal("https://auth.example.com", claims["aud"])
end
def test_token_request_params_is_nil_by_default
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
assert_nil(provider.token_request_params)
end
def test_initialize_keeps_a_frozen_copy_of_token_request_params
params = { "audience" => +"https://api.example.com" }
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params)
params["audience"] << "/changed"
params["organization"] = "org_123"
assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
assert_predicate(provider.token_request_params["audience"], :frozen?)
end
def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself
RESERVED_TOKEN_REQUEST_PARAMS.each do |key|
error = assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{key.inspect}") do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" })
end
assert_includes(error.message, key.inspect)
end
end
def test_initialize_rejects_token_request_params_that_are_not_a_hash_of_strings
["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }].each do |params|
assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{params.inspect}") do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params)
end
end
end
def test_initialize_rejects_token_request_params_that_compare_keys_by_identity
# Two equal keys are two entries in such a Hash and would silently collapse into one when copied.
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
token_request_params: { "audience" => "x" }.compare_by_identity,
)
end
assert_match(/identity/, error.message)
end
def test_initialize_copies_keys_that_hash_would_share_with_the_caller
# `Hash` copies and freezes only keys whose class is exactly `String`; a subclass key would otherwise
# stay shared with the caller, and a later `replace` would turn it into a reserved name.
key = Class.new(String).new("audience")
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" })
key.replace("grant_type")
assert_equal({ "audience" => "x" }, provider.token_request_params)
refute_same(key, provider.token_request_params.keys.first)
assert_predicate(provider.token_request_params.keys.first, :frozen?)
end
def test_initialize_keeps_empty_token_request_params_as_a_frozen_empty_hash
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: {})
assert_equal({}, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
end
def test_initialize_writes_no_client_information_when_token_request_params_are_rejected
storage = InMemoryStorage.new
assert_raises(Flow::InvalidTokenRequestParamsError) do
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
storage: storage,
token_request_params: { "grant_type" => "password" },
)
end
assert_nil(storage.client_information)
end
end
end
end

View file

@ -49,36 +49,6 @@ module MCP
)
end
def test_initialize_keeps_the_http_client_customizer
customizer = ->(_faraday) {}
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
http_client_customizer: customizer,
)
assert_same(customizer, provider.http_client_customizer)
assert_nil(build_provider.http_client_customizer)
end
def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials
storage = InMemoryStorage.new
error = assert_raises(ArgumentError) do
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
storage: storage,
http_client_customizer: "recorder",
)
end
assert_equal("http_client_customizer must respond to call (got String).", error.message)
assert_nil(storage.client_information)
end
def test_initialize_rejects_missing_client_id
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
CrossAppAccessProvider.new(
@ -118,42 +88,6 @@ module MCP
provider.clear_tokens!
assert_nil(provider.tokens)
end
def test_token_request_params_is_nil_by_default
assert_nil(build_provider.token_request_params)
end
def test_initialize_keeps_a_frozen_copy_of_token_request_params
params = { "audience" => +"https://api.example.com" }
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
token_request_params: params,
)
params["audience"] << "/changed"
assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
end
def test_initialize_writes_no_client_information_when_token_request_params_are_rejected
storage = InMemoryStorage.new
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
storage: storage,
token_request_params: { "assertion" => "x" },
)
end
assert_includes(error.message, '"assertion"')
assert_nil(storage.client_information)
end
end
end
end

View file

@ -259,19 +259,6 @@ module MCP
)
end
def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling
# Reported next to a failed request, the URL must still match the server's access log, so the host
# and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding.
assert_equal(
"https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson",
Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"),
)
end
def test_redact_url_does_not_echo_a_url_it_cannot_parse
assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc"))
end
def test_canonicalize_url_normalizes_query_to_match_faraday
# Faraday rewrites `env.url` before sending a request: it sorts
# parameters by name, uppercases percent-encoded hex, and drops

Some files were not shown because too many files have changed in this diff Show more