Compare commits
28 changed files with 219 additions and 2784 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -7,19 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.1] - 2026-09-09
|
||||
|
||||
This release keeps the HTTP client working with json 3.0. That release accepts the options of `JSON.parse`
|
||||
as keywords only, and Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash,
|
||||
which Ruby 3 no longer converts, so every JSON body the client received failed as an internal error.
|
||||
The client now parses response bodies itself, the way it already handled streamed ones, and no longer
|
||||
registers the middleware. A malformed JSON body delivered whole by an adapter without streaming support
|
||||
now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Parse HTTP client response bodies without Faraday's JSON middleware, which json 3.0 breaks (#546)
|
||||
|
||||
## [1.5.0] - 2026-09-05
|
||||
|
||||
This release makes the client answer a server's `ping` with the empty result the specification requires.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -59,27 +59,12 @@ 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
|
||||
|
||||
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
|
||||
|
||||
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser`
|
||||
to read SSE (`text/event-stream`) responses.
|
||||
Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both,
|
||||
so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always
|
||||
does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well.
|
||||
`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to
|
||||
answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode)
|
||||
serving handshake-lifecycle clients, and only while no listening stream is opened:
|
||||
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses:
|
||||
|
||||
```ruby
|
||||
gem "mcp"
|
||||
|
|
@ -248,9 +233,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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -38,16 +38,6 @@ $ gem install mcp
|
|||
You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem:
|
||||
|
||||
```ruby
|
||||
gem "mcp"
|
||||
gem "faraday", ">= 2.0"
|
||||
```
|
||||
|
||||
Reading SSE (`text/event-stream`) responses needs `event_stream_parser` as well.
|
||||
Whether a response is JSON or SSE is the server's choice, made for each response, and this SDK's own server picks SSE by default,
|
||||
so a client written for arbitrary servers needs both gems:
|
||||
|
||||
```ruby
|
||||
gem "faraday", ">= 2.0"
|
||||
gem "event_stream_parser", ">= 1.0"
|
||||
```
|
||||
|
||||
The [Transports](/client/transports/#http-transport-layer) page describes the one setup where `faraday` alone is enough.
|
||||
|
|
|
|||
|
|
@ -723,8 +723,7 @@ module MCP
|
|||
require_faraday!
|
||||
@client ||= Faraday.new(url) do |faraday|
|
||||
faraday.request(:json)
|
||||
# The client parses response bodies itself (`resolve_response_body`), so streamed and buffered responses
|
||||
# take the same path; Faraday's json response middleware is deliberately left out.
|
||||
faraday.response(:json)
|
||||
faraday.response(:raise_error)
|
||||
|
||||
faraday.headers["Accept"] = ACCEPT_HEADER
|
||||
|
|
@ -1075,8 +1074,8 @@ module MCP
|
|||
elsif content_type&.include?("application/json")
|
||||
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
|
||||
|
||||
# Adapters without `on_data` support deliver the body via `response.body`; a JSON middleware
|
||||
# added by the Faraday customizer may have parsed it already.
|
||||
# Adapters without `on_data` support deliver the body via `response.body`,
|
||||
# already parsed by the json response middleware.
|
||||
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
|
||||
else
|
||||
raise RequestHandlerError.new(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MCP
|
||||
VERSION = "1.5.1"
|
||||
VERSION = "1.5.0"
|
||||
end
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue