Compare commits

...
Author SHA1 Message Date
Koichi ITO
538a364c9b
Merge pull request #563 from koic/validate_icon_src_and_sizes
Some checks failed
Conformance Tests / server-conformance (push) Has been cancelled
Validate the src and sizes of an icon against the specification
2026-09-20 01:04:14 +09:00
Koichi ITO
66b2efa11e
Merge pull request #561 from koic/propagate_network_errors_from_protected_resource_metadata_discovery
Surface unreachable Protected Resource Metadata instead of falling back to legacy discovery
2026-09-20 00:03:41 +09:00
Koichi ITO
8173a38280 Validate the src and sizes of an icon against the specification
## Motivation and Context

`MCP::Icon` validated `theme` but accepted anything for `src` and `sizes`: `src:` defaulted to `nil`,
so `MCP::Icon.new.to_h` was `{}`, an icon with no source, and a `sizes` given as a String reached `to_h` unchanged
and serialized as a JSON string where the specification's `Icon` type requires an array of strings.
Nothing on the server side reported either; the server answered every request with 200 and the failure surfaced
in a client, with no reference to the field responsible. The TypeScript SDK types `sizes` as `string[]`
and `src` as required, and the Python SDK's model refuses such an icon at construction, so this SDK was
the one that let it through.

`src` is now a required keyword and must be a non-empty String, `sizes` must be `nil` or an Array of Strings,
and `mime_type` must be `nil` or a String, each refused with `ArgumentError` at construction the way `theme` already was.
The checks stop at the schema's types, as the reference SDKs do: the scheme of a URL and the `WxH` form of a size stay
the caller's to get right. A message names the class of the rejected value, never the value,
since `src` may carry a `data:` URI of any length. Coercing a String into a one-element Array was considered and rejected:
it would hide the caller's mistake that the class exists to surface. The `theme` check let `false` through its truthiness test
and serialized it; it now refuses everything but `nil`, `"light"`, and `"dark"`.

Fixes #562.

## How Has This Been Tested?

New tests in `test/mcp/icon_test.rb`. Against the previous library, an icon without `src` and an icon whose `sizes` is
a String are both constructed and serialized.

## Breaking Changes

`MCP::Icon.new` now raises `ArgumentError` where earlier releases produced an icon the specification's schema rejects:
`src:` is required and must be a non-empty String, so a call without it, or with `nil`, an empty String, or a non-String,
fails; `sizes:` must be `nil` or an Array of Strings, so a String, or an Array holding `nil` or a non-String, fails;
`mime_type:` must be `nil` or a String; `theme:` must be `nil`, `"light"`, or `"dark"`, so `false` fails where it used
to be serialized. Leaving the optional keywords out, or passing `nil` for them, is unchanged.
2026-09-19 17:32:45 +09:00
Koichi ITO
5f429f3fbf
Merge pull request #560 from koic/let_a_provider_customize_the_oauth_http_client
Let a provider customize the HTTP client the OAuth flow uses
2026-09-19 08:00:02 +09:00
Koichi ITO
e1105c7094
Merge pull request #559 from koic/let_providers_without_a_cimd_url_refresh
Let providers without a CIMD URL reader refresh their tokens
2026-09-19 00:29:07 +09:00
Koichi ITO
5081cc5626 Surface unreachable Protected Resource Metadata instead of falling back to legacy discovery
## Motivation and Context

`Flow#locate_authorization_server` treated every failure to fetch Protected Resource Metadata as "the server publishes none"
and took the legacy 2025-03-26 path, where the MCP server's origin becomes the authorization base URL.
A request that never reached the server, or a `5xx` or `429`, says nothing about what the server publishes,
so a transient failure moved the flow to a different authorization server, discarded client information
and tokens bound to the real one on the way, and surfaced as an unrelated error, such as a failed registration at the origin,
rather than as the fetch failure itself. The comment claimed both reference SDKs behave that way, which is not so:
outside browsers the TypeScript SDK propagates network errors from discovery, and the Python SDK propagates them
and refuses the legacy path after a `5xx` or `429` from any candidate.

Discovery now keeps trying candidate URLs until one serves a JSON object and classifies the outcome once they are exhausted.
Like the Python SDK, it remembers a `5xx` or `429` from any candidate before deciding on the legacy path;
moving on to the next candidate after a network error is what the loop already did. `MetadataNotPublishedError`
(a `4xx` other than `429`, or a body that is not a JSON object) is the only failure that selects the legacy path;
`MetadataUnreachableError` (a network error, or a `5xx` or `429`, from any candidate) is raised instead.
Both subclass `AuthorizationError`, so callers rescuing that class keep working, and `MCP::Client::HTTP` still falls back from
a failed refresh to the full flow, which repeats discovery and raises the same way unless the server has recovered.
A body over the response cap is refused outright with a plain `AuthorizationError`, as it already was on the modern path.
The message lists every candidate's failure, with the URL stripped of userinfo, query and fragment and cut to a fixed length,
but otherwise spelled as requested, and with transport and parser messages bounded, as token endpoint diagnostics already are.
The default legacy endpoints for an origin whose own metadata document is absent are unchanged.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/discovery_test.rb`. Against the library
before this change, discovery in the scenarios these tests set up took the legacy path instead of raising.

## Breaking Changes

When no Protected Resource Metadata candidate serves a JSON object, a request that failed to reach the server,
or returned a `5xx` or `429`, now raises instead of attempting the legacy 2025-03-26 discovery at the MCP server's origin,
and a Protected Resource Metadata body over the response cap is refused instead of triggering that fallback.
2026-09-19 00:18:09 +09:00
Koichi ITO
2c33ad061a
Merge pull request #557 from koic/run_the_extension_grants_without_protected_resource_metadata
Let the client_credentials and jwt-bearer grants run without Protected Resource Metadata
2026-09-19 00:12:33 +09:00
Koichi ITO
9455c41b14
Merge pull request #556 from koic/validate_the_issuer_of_legacy_authorization_server_metadata
Validate the issuer of legacy authorization server metadata
2026-09-19 00:11:34 +09:00
Koichi ITO
c9f381a3ee Let a provider customize the HTTP client the OAuth flow uses
## Motivation and Context

`MCP::Client::OAuth::Flow` takes an `http_client_factory:`, but `MCP::Client::HTTP` builds
the flow itself and passes none, so an application using `oauth:` could not add middleware to
the requests the flow makes: Protected Resource Metadata and authorization server metadata discovery,
dynamic client registration, and every token request.
The block that customizes the transport's Faraday connection does not help,
because that connection is bound to the MCP server URL and carries the `headers:` meant for that server,
and the authorization server is a different origin. The TypeScript SDK hands the transport's `fetch` to `auth()`
and the Python SDK sends the provider's requests through the transport's httpx client, so both let
an application instrument these requests; this SDK did not. (#558)

Providers now take `http_client_customizer:`, a callable invoked with the Faraday connection
the flow builds for its own requests, next to the other callables a provider already holds.
It runs after the SDK's defaults (`Accept: application/json`, no redirect following) and
between two middlewares of the SDK's own: `Flow::RequestedURLStamp`, registered before the customizer is invoked,
records the URL the flow asked for on the request environment, and `Flow::RequestedOriginGuard`, registered last,
sees what the customizer's middleware did to the request after that record. A request that would leave that origin,
by following a redirect or rewriting the URL, or that arrives without the record, is refused with
`Flow::DestinationMismatchError` before it reaches the adapter. The record lives on the environment rather than
in `env.request.context`, which stays the application's to fill or replace. The error is an `ArgumentError`
rather than an `AuthorizationError` so neither discovery nor the transport's refresh fallback can absorb
a configuration mistake. The boundary resembles the one the Python SDK keeps for its own requests,
which follows a redirect only within the origin; this flow follows none itself.
`Flow.build_http_client` assembles that connection and `Flow.new(http_client_factory:)` keeps replacing it whole.
Following done inside an adapter cannot be seen by any guard, so the documentation keeps that, `Accept-Encoding`,
and `raise_error` off the list of things a customizer may add, and notes that a recording middleware sees
client credentials and tokens.

Fixes #558.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb`, `test/mcp/client/oauth/http_oauth_test.rb`,
and the three provider test files. Against the library before this change, every one of them fails,
on the unknown keyword or on the missing reader.

## Breaking Changes

None. The customizer is opt-in, and the guard cannot fire on the SDK's own middleware-free connection.
2026-09-18 13:04:33 +09:00
Koichi ITO
f51ccfbe66 Let providers without a CIMD URL reader refresh their tokens
## Motivation and Context

`Flow#refresh!` reads `client_id_metadata_document_url` from the provider unconditionally, once to decide whether
a provider without stored `client_information` may still refresh and once to build the CIMD `client_id`.
Only `Provider` defines that reader: `CrossAppAccessProvider` and `ClientCredentialsProvider`
raise `NoMethodError` once a stored `refresh_token` is found. `MCP::Client::HTTP` calls `refresh!` on any `401` while
a `refresh_token` is stored, so a `jwt-bearer` client whose authorization server issued one fails with
`NoMethodError` on the first expired token instead of refreshing. The error is not an `AuthorizationError`,
so the transport does not fall back to the grant either. RFC 7521 Section 4.1 makes a refresh token unusual
for an assertion grant, not forbidden, and one in storage must not crash the client.

The flow now reads the URL through a duck-typed helper, like `authorization_flow` and `client_assertion`,
so a provider without the reader has no CIMD URL and refreshes with its stored `client_information`:
the `refresh_token` grant is sent with the stored client authentication, and no new ID-JAG assertion is obtained.
The authorization-code registration path reads the URL through the same helper.
The docs now say that a refresh token issued anyway is used on the next `401`, and that the assertion provider
is not called again on refresh.
A refresh token stored without the `issuer` the SDK records is refused for these two providers before any request:
unlike `Provider`, they have no tokens that predate the record, and a refresh asks no validator, so such a token
would be presented to whatever authorization server discovery names. The refusal is an `AuthorizationError`,
which sends the transport back through the grant, and the grant asks.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` refresh through a `CrossAppAccessProvider` and
a `ClientCredentialsProvider` holding a `refresh_token`, asserting the `refresh_token` grant, the Basic credentials,
and that the assertion provider is not called. A transport test in `test/mcp/client/oauth/http_oauth_test.rb` sends
a `401` to a `CrossAppAccessProvider` with a stored `refresh_token` and expects the refreshed bearer token on the retry.
All three raise `NoMethodError` against the previous library.
Further tests pin the issuer stamp on the refreshed tokens, the refusal of an authorization server that did not issue them
for a `CrossAppAccessProvider`, a clean `AuthorizationError` for a provider with neither the reader
nor stored `client_information`, and registration through DCR for a provider without the reader.
A refresh token without a recorded issuer is refused for both providers before any request is sent.

## Breaking Changes

None.
2026-09-18 11:17:21 +09:00
Koichi ITO
f22ce86e97
Merge pull request #555 from koic/add_token_request_params
Let a provider add parameters to the token requests it makes
2026-09-18 11:13:58 +09:00
Koichi ITO
29e8d3f2b9 Let the client_credentials and jwt-bearer grants run without Protected Resource Metadata
## Motivation and Context

When a server publishes no Protected Resource Metadata, discovery takes the legacy 2025-03-26 path and `prm` is `nil`.
The authorization-code branch of `run!` allowed for that by passing `prm || {}` to `resolve_scope`,
but `run_client_credentials!` and `run_jwt_bearer!` passed `prm` as is, and `resolve_scope` then
called `prm["scopes_supported"]` on `nil`. Without a non-empty challenge scope, a `ClientCredentialsProvider` or
`CrossAppAccessProvider` pointed at such a server therefore failed with `NoMethodError` after discovery and before
the token request or the ID-JAG callback, instead of running its grant against the legacy authorization base
the way `Provider` does.

`resolve_scope` now treats a missing PRM as advertising no scopes, so the challenge scope or the provider's own scope applies,
and the authorization-code branch passes `prm` unchanged like the other two.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` run the `client_credentials` grant against a PRM-less server with metadata
at the origin, checking the provider's scope, the `resource`, and the recorded issuer, and run the `jwt-bearer` grant against
a server without any metadata, checking the default token endpoint, the assertion audience, the `resource`, and the recorded
issuer. Both raise `NoMethodError` against the previous library.

## Breaking Changes

None.
2026-09-17 19:05:51 +09:00
Koichi ITO
47eb74aa7a Validate the issuer of legacy authorization server metadata
## Motivation and Context

On the legacy 2025-03-26 path, taken when a server publishes no Protected Resource Metadata,
the authorization server metadata is fetched from the MCP server's origin, which that spec names
as the authorization base URL, but its `issuer` was never compared with that origin.
RFC 8414 Section 3.3 requires the comparison, the 2025-03-26 spec makes RFC 8414 a MUST for clients,
and the TypeScript and Python SDKs both compare on this path, tolerating only a trailing slash.
The flow nevertheless used the unverified `issuer` as the authorization server's identity everywhere:
tokens were stamped with it, stored client information was matched against it, the `private_key_jwt`
and ID-JAG assertions took it as their audience, and `authorization_request_validator` was shown it.

A server on that path could therefore claim any issuer. Claiming the one that minted the stored tokens made
`refresh!` send the refresh token and the stored client secret to the server's own token endpoint, claiming
the one that issued stored client information made the flow reuse those credentials there, and a validator
allow-listing the claimed issuer approved the request.

The legacy path now applies the same check as the modern one: the document's `issuer` must be the origin,
rendered with or without a trailing slash, and any other document is refused before registration, redirect,
or token request. A server without a metadata document still gets the spec's default endpoints.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` serve legacy metadata at the MCP server origin that claims
`https://auth.example.com` and check that the flow, a refresh of tokens issued by that server,
and a flow holding client information bound to it are all refused before any request, with the validator never asked
and the credentials never presented. Metadata naming the origin, with or without a trailing slash and with
endpoints under a path prefix, completes the flow, including the RFC 9207 `iss` check. The former test
of a path-prefix issuer now expects the refusal. The refusal tests fail against the previous library.

## Breaking Changes

A server without Protected Resource Metadata whose authorization server metadata names an issuer other than
its origin is refused instead of used. The TypeScript and Python SDKs already refuse such a server,
and the 2025-03-26 spec places the metadata at the origin.
2026-09-17 13:37:54 +09:00
Koichi ITO
8ad80dd0b4 Let a provider add parameters to the token requests it makes
## Motivation and Context

`ClientCredentialsProvider` builds a fixed `client_credentials` token request: `grant_type`, `scope`,
`resource`, and the client authentication that `Flow#post_to_token_endpoint` adds. An authorization server
that requires further parameters, such as Auth0's `audience`, cannot be used through `oauth:` at all,
and giving up `oauth:` also gives up the bearer header and the 401-driven retry that the transport provides.

RFC 6749 Section 8.2 allows extension parameters on the token request, and the TypeScript SDK lets
a provider shape its token request through `prepareTokenRequest`. `ClientCredentialsProvider.new` now takes
`token_request_params:`, a Hash of String keys and values added to every token request the provider makes,
and `Flow#post_to_token_endpoint` reads the same name by duck typing, so a `Provider` or `CrossAppAccessProvider`
subclass that defines the method gets the same treatment on its authorization code exchange, refresh,
or `jwt-bearer` request.
`Provider.new` and `CrossAppAccessProvider.new` take the same keyword, held by `StorageBackedProvider` like
`authorization_request_validator`, so every bundled provider refuses a bad value before anything is stored.

The parameters go underneath the flow's own, so the SDK's values always win, and a key the SDK sets itself
(`Flow::RESERVED_TOKEN_REQUEST_PARAMS`) is refused rather than silently overridden: with `ArgumentError`
from the constructor, before any `client_information` is written, and again with `ArgumentError` from the flow,
before the token request is sent. Only Strings are accepted because `URI.encode_www_form` encodes other scalars
and Arrays in ways the caller did not write, and the provider keeps a frozen copy so a later change to
the caller's Hash cannot alter what is sent.
A Hash comparing keys by identity is refused too, since two equal keys are two entries there, sent twice
by a provider method or silently collapsed into one by the copy. The flow raises `ArgumentError` rather than
`AuthorizationError` because `MCP::Client::HTTP` treats a failed refresh as a reason to run the interactive flow,
which would then fail the same way after the user signed in.
Both refusals raise `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, so a caller can tell them
from an unknown keyword, and the copy duplicates keys as well as values, since `Hash` copies only keys
whose class is exactly `String`.

Fixes #554.

## How Has This Been Tested?

New tests cover every token request the parameters can ride (the `client_credentials` grant under
all three client authentication methods, the authorization code exchange, refresh, and `jwt-bearer`),
both refusal boundaries (the constructors of all three providers, and the flow for a provider defining the method),
and the transport surfacing a refused hook from a refresh attempt without starting the interactive flow.
All of them fail against the previous library.

## Breaking Changes

None. The keyword is optional and the hook is opt-in; a provider that does not define `token_request_params` sends
the same requests as before.
2026-09-17 13:26:52 +09:00
Koichi ITO
41fbf05d26
Merge pull request #553 from koic/suppress_rubocop_offense
Fix a redundant regexp escape in the tool name validation
2026-09-16 00:44:04 +09:00
Koichi ITO
99ef8732fe Fix a redundant regexp escape in the tool name validation
## Motivation and Context

This PR suppresses the following offense of RuboCop.

```console
$ bundle exec rubocop
Inspecting 163 files
(snip)

Offenses:

lib/mcp/tool.rb:152:48: C: [Correctable] Style/RedundantRegexpEscape: Redundant escape inside regexp literal
        unless tool_name.match?(/\A[A-Za-z\d_\-\.]+\z/)
                                               ^^

163 files inspected, 1 offense detected, 1 offense autocorrectable
```

## How Has This Been Tested?

`bundle exec rake` passes: RuboCop reports no offenses.

## Breaking Changes

None.
2026-09-15 12:36:59 +09:00
Koichi ITO
7d7a5669ef
Merge pull request #549 from junyuanz1/fix/oauth-token-error-diagnostics
fix(client/oauth): preserve token endpoint error diagnostics
2026-09-15 00:38:02 +09:00
Junyuan Zheng
8d1d5be4ba fix(client/oauth): preserve token endpoint error diagnostics 2026-09-14 09:45:38 -04:00
Koichi ITO
a18c628787
Merge pull request #552 from koic/document_listen_delivery_semantics
[Doc] State the Listen Stream Delivery Semantics
2026-09-14 22:29:28 +09:00
Koichi ITO
ef3eb7b0b2 [Doc] State the Listen Stream Delivery Semantics
The Subscriptions page documented the acknowledgement-first contract but not what delivery means:
the stream carries only changes that happen after the acknowledgement, nothing is queued while
no stream is connected, and a dropped stream is gone together with anything it missed.
The Python SDK's subscriptions page states the same contract ("It is not a replay log").
Spelling it out gives client implementers the fetch-after-subscribing guidance and
makes the acknowledgement-window behavior explicit.
2026-09-13 17:10:12 +09:00
Koichi ITO
8e0ae73cc1
Merge pull request #551 from koic/fill_the_client_ping_docs_gaps
[Doc] Document the client's ping answers and the stdio request limit
2026-09-13 00:16:28 +09:00
Koichi ITO
7e507f2963 [Doc] Document the client's ping answers and the stdio request limit
## Motivation and Context

The client answers server pings automatically, but the docs leave out where that applies and what the stdio transport
does with the other server-to-client requests.

The client Transports page now lists the automatic ping answer among the things the stdio transport handles,
and a note states that `ping` is the only server-to-client request answered there: a wire-level `elicitation/create`
or `sampling/createMessage` is ignored over stdio, so a server that sends one waits for an answer that never comes,
while the SEP-2322 `input_required` route works on every transport and keeps `on_elicitation` and `on_sampling` handlers
working over stdio. Without this note, the only trace of the limitation is a hung tool call.

The client Ping page now says that over HTTP a ping reaches the client only on a stream that is already open
and that the client never opens one to receive pings, so a server can only ping a client that has a stream open.
Registering `on_server_request("ping")` moves to the end of the section as the advanced option it is, together with
its side effect of opening the GET listening stream on `connect`.

The client overview mentions the automatic answer next to `MCP::Client#ping`,
and the server Ping page's Client Side section points at it.

## How Has This Been Tested?

Documentation only; the linked headings and pages exist.

## Breaking Changes

None.
2026-09-12 00:02:36 +09:00
Koichi ITO
251a40f780
Merge pull request #550 from koic/drop_the_json_pin_from_the_gemfile
Drop the json pin for Ruby 2.7.0 through 2.7.2 from the Gemfile
2026-09-11 00:04:56 +09:00
Koichi ITO
2f65a6e502 Drop the json pin for Ruby 2.7.0 through 2.7.2 from the Gemfile
## Motivation and Context

json 3.0.2 loads on Ruby 2.7.0 through 2.7.2 again: it no longer forwards arguments after a leading parameter,
the syntax those patch levels could not parse, so the Gemfile no longer has to keep json below 3.0 there,
and the CI job pinned to 2.7.0, the minimum the gemspec supports, resolves json 3.0.2 like every other job.
The maintainers intend to raise json's own Ruby floor to 2.7.3 in the release after 3.0.2, so those patch levels
stay on 3.0.2 by json's gemspec from then on, and no pin of this Gemfile's own is needed for that either.

## How Has This Been Tested?

A fresh `bundle install` on Ruby 2.7.2 resolves json 3.0.2, `require "json"` loads it there,
and `bundle exec rake test` passes. Ruby 3.0 and later were never affected by the pin.

## Breaking Changes

None. The Gemfile is not part of the gem.
2026-09-10 11:55:31 +09:00
Koichi ITO
91ee5a2123
Merge pull request #548 from junyuanz1/fix/token-endpoint-single-auth-method
fix(client/oauth): omit body client_id when using HTTP Basic
2026-09-10 01:31:43 +09:00
Junyuan Zheng
4192ea58ea fix(client/oauth): omit body client_id when using HTTP Basic 2026-09-09 12:14:21 -04:00
Koichi ITO
c6a5512381
Merge pull request #547 from koic/release_1_5_1
Release 1.5.1
2026-09-09 14:43:47 +09:00
Koichi ITO
3b44ec4f48 Release 1.5.1
This release keeps the HTTP client working with json 3.0, which accepts the options of `JSON.parse`
as keywords only. Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash
that 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.

The one visible difference: 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,
instead of `:internal_error` wrapping a `Faraday::ParsingError`.
2026-09-09 10:43:15 +09:00
Koichi ITO
d0b22ef722
Merge pull request #546 from koic/parse_json_response_bodies_in_the_client
Parse HTTP client response bodies without Faraday's JSON middleware
2026-09-08 18:13:24 +09:00
Koichi ITO
d40d9018d5 Keep json below 3.0 on Ruby 2.7.0 through 2.7.2 in the Gemfile
## Motivation and Context

json 3.0 declares Ruby 2.7 as its floor, but its `load_file` forwards arguments after a leading parameter,
`def load_file(filespec, ...)`, syntax Ruby 2.7.3 was the first to parse. On Ruby 2.7.0 through 2.7.2, requiring
json 3.0.1 is a `SyntaxError`, so `require "mcp"` fails before any test runs, and the CI job pinned to 2.7.0,
the minimum the gemspec supports, fails the whole suite at load time.

The Gemfile keeps json below 3.0 on those three patch levels only. Later 2.7 releases and Ruby 3 parse the syntax
and stay on whatever json the resolver picks.
The line carries a FIXME to drop it once a json release includes ruby/json#1072, whichever version that turns out to be;
excluding only the two broken releases would silently trust a 3.0.2 that may not carry the fix.

## How Has This Been Tested?

`gem "json", "3.0.1"; require "json"` raises the `SyntaxError` on Ruby 2.7.0 and loads on 2.7.3 and 2.7.8.
With the pin, a fresh `bundle install` on Ruby 2.7.2 resolves json 2.x and `bundle exec rake test` passes.

## Breaking Changes

None. The Gemfile is not part of the gem.
2026-09-08 18:09:09 +09:00
Koichi ITO
09c89aef14 Parse HTTP client response bodies without Faraday's JSON middleware
## Motivation and Context

json 3.0, released on 2026-09-07, accepts the options of `JSON.parse` as keywords only, and Faraday's json response middleware,
through 2.14.3, hands them over as a positional Hash, which Ruby 3 no longer converts. With both installed, every JSON body
the client receives fails inside that middleware, and `MCP::Client::HTTP` reports each request as an internal error.
Faraday's main branch passes the options as keywords, but no release carries that yet.

The client already parses streamed bodies and buffered SSE bodies itself; only a JSON body delivered
whole by an adapter without streaming support still went through the middleware. The client now parses that body too
and no longer registers the middleware, so every body takes `parse_json_buffer` and a malformed one answers
`:parse_error` whichever way it arrived. A JSON middleware added through the Faraday customizer block still works,
since a body it has parsed already is used as is.

## How Has This Been Tested?

A new test drives a buffered JSON response through a stand-in `JSON.parse` with the json 3.0 signature, options as keywords only,
so on Ruby 3 it failed with the middleware registered whatever json version is installed and passes now.
The existing buffered-body test keeps covering the plain path. The full suite passes on Ruby 3.0.7 with json 3.0.1 and faraday 2.14.3,
where five client tests failed before.

## Breaking Changes

None. A malformed JSON body delivered by an adapter without streaming support now raises `RequestHandlerError` with `error_type: :parse_error`,
as the streamed path already did, instead of `:internal_error` wrapping a `Faraday::ParsingError`.
2026-09-08 18:09:09 +09:00
Koichi ITO
44af58c7e0
Merge pull request #544 from koic/add_event_stream_parser_to_installation_docs
[Doc] Explain when the HTTP client needs event_stream_parser
2026-09-07 08:57:35 +09:00
Koichi ITO
0da0291887 [Doc] Explain when the HTTP client needs event_stream_parser
## Motivation and Context

The Installation page names faraday as the example of an optional dependency but leaves out event_stream_parser,
which the HTTP client transport needs as soon as a server answers with SSE. The Transports page documents both gems,
so a reader who starts from Installation learns about the second one only when a streaming response fails to parse.

The Transports page also marks event_stream_parser as "optional, required only for SSE responses",
which reads as a corner case. Whether a response is JSON or SSE is the server's choice, made for each response,
a client must accept both, and this SDK's own server picks SSE by default and always on the modern lifecycle,
so a client written for arbitrary servers needs both gems. Both pages now say so, and the Transports page spells out
the one setup that can leave the gem out: a server known to answer with JSON alone, such as this SDK's server
in JSON response mode serving handshake-lifecycle clients, and no listening stream.

The Installation page shows the faraday-only Gemfile first and then the one that adds event_stream_parser,
so both cases are visible at a glance.

## How Has This Been Tested?

Documentation only; the Gemfile snippets on both pages match.

## Breaking Changes

None.
2026-09-06 03:22:32 +09:00
28 changed files with 2788 additions and 223 deletions

View file

@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.5.0] - 2026-09-05
This release makes the client answer a server's `ping` with the empty result the specification requires. This release makes the client answer a server's `ping` with the empty result the specification requires.

View file

@ -37,9 +37,13 @@ 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), - 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. 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 - 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` without the RFC 8414 issuer byte-match (which the legacy spec predates), 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),
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. 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). - 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), - 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. 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. 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.
@ -95,6 +99,8 @@ Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. - `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. - `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). 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`, - `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 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 issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
@ -112,6 +118,14 @@ Optional keyword arguments:
served at the URL is a separate JSON artifact from the `client_metadata` keyword above: 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 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`. `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 } {: .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, > 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,
@ -159,11 +173,33 @@ 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 ### Client Credentials Grant
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. 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 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 does not issue a refresh token. 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`.
```ruby ```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new( provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
@ -171,6 +207,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_secret: ENV.fetch("MCP_CLIENT_SECRET"), client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt" # 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) # 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) transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
@ -186,13 +223,15 @@ Keyword arguments:
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string - `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 `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. or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`. - `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`.
### Cross-App Access (JWT Bearer) Grant ### 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`. 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 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`. 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. 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 `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
@ -223,7 +262,39 @@ Keyword arguments:
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. - `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). `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. Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`. - `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.
### Communication Security ### Communication Security
@ -255,8 +326,11 @@ 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 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. 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.
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 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
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused. 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.
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, 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. 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,7 +17,8 @@ This class supports:
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle - Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
when the server serves it; see [Lifecycle](/client/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) - 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`) - Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings;
see [Answering Server Pings](/client/ping/#answering-server-pings)
- Tool listing via the `tools/list` method (`MCP::Client#tools`) - Tool listing via the `tools/list` method (`MCP::Client#tools`)
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) - Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
- Resource listing via the `resources/list` method (`MCP::Client#resources`) - Resource listing via the `resources/list` method (`MCP::Client#resources`)

View file

@ -32,13 +32,17 @@ propagate as exceptions raised by the transport layer.
## Answering Server Pings ## Answering Server Pings
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with 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. Registering `transport.on_server_request("ping")` on `MCP::Client::HTTP` replaces the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open
the automatic answer. (the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings.
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when 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 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:` 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. 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 ## Server Side
How servers answer `ping` requests and ping the client themselves is documented on How servers answer `ping` requests and ping the client themselves is documented on

View file

@ -59,12 +59,27 @@ The stdio transport automatically handles:
- Spawning the server process with `Open3.popen3` - Spawning the server process with `Open3.popen3`
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) - MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
- JSON-RPC 2.0 message framing over newline-delimited JSON - 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 ## HTTP Transport Layer
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. 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. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: 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:
```ruby ```ruby
gem "mcp" gem "mcp"
@ -233,6 +248,9 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
end 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 } {: .note }
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside > 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. > the SSE streaming callback of another response, re-entering the connection on the same thread.

View file

@ -48,3 +48,5 @@ Server-to-client requests are bounded by a timeout on the Streamable HTTP transp
## Client Side ## Client Side
Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page. 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,6 +49,13 @@ 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 ## Transport Support
The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. The stream is served on the Streamable HTTP modern path; stdio answers `-32601`.

View file

@ -38,6 +38,16 @@ $ 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: 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 ```ruby
gem "mcp"
gem "faraday", ">= 2.0" 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.

View file

@ -723,7 +723,8 @@ module MCP
require_faraday! require_faraday!
@client ||= Faraday.new(url) do |faraday| @client ||= Faraday.new(url) do |faraday|
faraday.request(:json) faraday.request(:json)
faraday.response(: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(:raise_error) faraday.response(:raise_error)
faraday.headers["Accept"] = ACCEPT_HEADER faraday.headers["Accept"] = ACCEPT_HEADER
@ -1074,8 +1075,8 @@ module MCP
elsif content_type&.include?("application/json") elsif content_type&.include?("application/json")
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty? return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
# Adapters without `on_data` support deliver the body via `response.body`, # Adapters without `on_data` support deliver the body via `response.body`; a JSON middleware
# already parsed by the json response middleware. # added by the Faraday customizer may have parsed it already.
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
else else
raise RequestHandlerError.new( raise RequestHandlerError.new(

View file

@ -26,7 +26,7 @@ module MCP
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates # 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 # `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 # 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#default_http_client`. # the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
def on_data def on_data
proc do |chunk, _received_bytes, _env| proc do |chunk, _received_bytes, _env|
@buffer << chunk @buffer << chunk

View file

@ -37,11 +37,17 @@ module MCP
# also take the algorithm as an explicit option). # also take the algorithm as an explicit option).
# - `scope` - String of space-separated scopes to request when the server's # - `scope` - String of space-separated scopes to request when the server's
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one. # `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
# `client_information`, and `save_client_information(info)`. Defaults to # and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
# an `InMemoryStorage`. The `client_id` / `client_secret` are written # The `client_id` / `client_secret` are written into it so the token exchange reads
# into it so the token exchange reads them through the same path as # them through the same path as a pre-registered authorization-code client.
# 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`.
class ClientCredentialsProvider class ClientCredentialsProvider
include StorageBackedProvider include StorageBackedProvider
@ -61,7 +67,9 @@ module MCP
signing_algorithm: nil, signing_algorithm: nil,
scope: nil, scope: nil,
storage: nil, storage: nil,
authorization_request_validator: nil authorization_request_validator: nil,
token_request_params: nil,
http_client_customizer: nil
) )
if blank?(client_id) if blank?(client_id)
raise InvalidCredentialsError, "client_id is required for the client_credentials grant." raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@ -99,12 +107,16 @@ module MCP
client_information["client_secret"] = client_secret client_information["client_secret"] = client_secret
end end
http_client_customizer = validated_http_client_customizer(http_client_customizer)
@client_id = client_id @client_id = client_id
@private_key = private_key @private_key = private_key
@signing_algorithm = signing_algorithm @signing_algorithm = signing_algorithm
@scope = scope @scope = scope
@storage = storage || InMemoryStorage.new @storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator @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) @storage.save_client_information(client_information)
end end

View file

@ -25,6 +25,12 @@ module MCP
# the Protected Resource Metadata do not specify one. # the Protected Resource Metadata do not specify one.
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
# Defaults to an `InMemoryStorage`. # 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 # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
class CrossAppAccessProvider class CrossAppAccessProvider
@ -35,7 +41,16 @@ module MCP
attr_reader :scope, :storage attr_reader :scope, :storage
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil) def initialize(
client_id:,
client_secret:,
assertion_provider:,
scope: nil,
storage: nil,
authorization_request_validator: nil,
token_request_params: nil,
http_client_customizer: nil
)
if blank?(client_id) if blank?(client_id)
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
end end
@ -48,10 +63,14 @@ module MCP
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion." raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
end end
http_client_customizer = validated_http_client_customizer(http_client_customizer)
@assertion_provider = assertion_provider @assertion_provider = assertion_provider
@scope = scope @scope = scope
@storage = storage || InMemoryStorage.new @storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator @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( @storage.save_client_information(
"client_id" => client_id, "client_id" => client_id,
"client_secret" => client_secret, "client_secret" => client_secret,

View file

@ -336,6 +336,29 @@ module MCP
uri.to_s uri.to_s
end 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): # 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 # 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 # also advertises a query string, the server's query MUST be identical to it (otherwise

View file

@ -14,7 +14,38 @@ module MCP
# `Provider`; this class consumes a Provider plus signal data extracted from # `Provider`; this class consumes a Provider plus signal data extracted from
# the failing response (resource_metadata URL, scope challenge). # the failing response (resource_metadata URL, scope challenge).
class Flow class Flow
class AuthorizationError < StandardError; end 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
# Raised specifically when the token endpoint rejects a grant with # Raised specifically when the token endpoint rejects a grant with
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
@ -28,6 +59,136 @@ module MCP
# or authorization server metadata failure by rescuing a class rather than by matching the message text. # or authorization server metadata failure by rescuing a class rather than by matching the message text.
class AuthorizationRefusedError < AuthorizationError; end 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) def initialize(provider:, http_client_factory: nil)
@provider = provider @provider = provider
@http_client_factory = http_client_factory || -> { default_http_client } @http_client_factory = http_client_factory || -> { default_http_client }
@ -75,7 +236,7 @@ module MCP
ensure_pkce_supported!(as_metadata) 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) 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 # Asked before registering, not after: a refusal must not leave this client registered at an authorization server
@ -126,7 +287,7 @@ module MCP
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists # 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 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, # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). # and no `offline_access` augmentation because the grant is not expected to 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`. # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:) def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
@ -219,19 +380,22 @@ module MCP
# checks before talking to it. # checks before talking to it.
# #
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, # 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. # or when the token endpoint refuses the refresh request.
# https://www.rfc-editor.org/rfc/rfc6749#section-6 # https://www.rfc-editor.org/rfc/rfc6749#section-6
def refresh!(server_url:, resource_metadata_url: nil) def refresh!(server_url:, resource_metadata_url: nil)
refresh_token = read_token("refresh_token") refresh_token = read_token("refresh_token")
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless 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 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") 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 # A CIMD-configured provider stores no `client_information` on purpose
# (the CIMD URL is re-resolved against the live AS metadata on every flow). # (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. # 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 unless have_stored_client_info || have_cimd_url
raise AuthorizationError, "Cannot refresh: no client_information in provider storage." raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
@ -267,7 +431,7 @@ module MCP
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata) ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
stored_client_info stored_client_info
elsif as_metadata["client_id_metadata_document_supported"] == true 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 else
raise AuthorizationError, raise AuthorizationError,
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \ "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
@ -319,7 +483,12 @@ module MCP
# #
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil # 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. # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior. # 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.
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
def locate_authorization_server(server_url:, resource_metadata_url:) def locate_authorization_server(server_url:, resource_metadata_url:)
prm = begin prm = begin
@ -327,7 +496,7 @@ module MCP
server_url: server_url, server_url: server_url,
resource_metadata_url: resource_metadata_url, resource_metadata_url: resource_metadata_url,
) )
rescue AuthorizationError rescue MetadataNotPublishedError
nil nil
end end
@ -349,16 +518,26 @@ module MCP
# Fetches and validates the authorization server's RFC 8414 metadata. # Fetches and validates the authorization server's RFC 8414 metadata.
# #
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3). # 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 that validation is skipped: the legacy spec predates the requirement, # On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from # the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path). # 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.
# When even the metadata document is absent, the legacy spec's default endpoints are used. # When even the metadata document is absent, the legacy spec's default endpoints are used.
def authorization_server_metadata(authorization_server:, legacy:, server_url:) def authorization_server_metadata(authorization_server:, legacy:, server_url:)
metadata = if legacy metadata = if legacy
begin fetched = begin
fetch_authorization_server_metadata(issuer_url: authorization_server) fetch_authorization_server_metadata(issuer_url: authorization_server)
rescue AuthorizationError rescue AuthorizationError
nil
end
if fetched
ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
fetched
else
default_legacy_metadata(authorization_server) default_legacy_metadata(authorization_server)
end end
else else
@ -403,6 +582,14 @@ module MCP
fetch_metadata_json(urls, label: "authorization server metadata") fetch_metadata_json(urls, label: "authorization server metadata")
end 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 # Reads `authorization_servers` from a PRM document and returns
# the first entry, raising `AuthorizationError` for any of the malformed # the first entry, raising `AuthorizationError` for any of the malformed
# shapes a non-compliant server could emit (missing field, non-Array # shapes a non-compliant server could emit (missing field, non-Array
@ -430,44 +617,63 @@ module MCP
first first
end end
# Walks candidate metadata URLs and returns the parsed JSON body of # Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
# the first 2xx response. Raises `AuthorizationError` for transport # the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`) # be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
# so callers do not have to handle raw Faraday/JSON exceptions. # 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.
def fetch_metadata_json(urls, label:) def fetch_metadata_json(urls, label:)
last_error = nil failures = []
inconclusive = false
urls.each do |url| urls.each do |url|
response = begin response = begin
http_get(url) http_get(url)
rescue Faraday::Error => e rescue Faraday::Error => e
last_error = "GET #{url} raised #{e.class}: #{e.message}" detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
inconclusive = true
next next
end end
if response.status >= 200 && response.status < 300 unless response.status >= 200 && response.status < 300
parsed = begin failures << "GET #{reported_url(url)} returned #{response.status}"
JSON.parse(response_body_string(response)) inconclusive = true if response.status >= 500 || response.status == 429
rescue JSON::ParserError => e next
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 end
last_error = "GET #{url} returned #{response.status}" 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
end end
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
end end
def ensure_pkce_supported!(as_metadata) def ensure_pkce_supported!(as_metadata)
@ -628,7 +834,7 @@ module MCP
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise # (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 # 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`. # 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 if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
return { "client_id" => cimd_url } return { "client_id" => cimd_url }
end end
@ -758,6 +964,18 @@ module MCP
MESSAGE MESSAGE
end 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:) def ensure_refreshable_client_information!(client_info, as_metadata:)
stored_issuer = client_info_required_value(client_info, "issuer") stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil? return if stored_issuer.nil?
@ -886,7 +1104,8 @@ module MCP
def resolve_scope(scope:, prm:) def resolve_scope(scope:, prm:)
return scope if scope && !scope.empty? return scope if scope && !scope.empty?
supported = prm["scopes_supported"] # `prm` is nil on the legacy path, where nothing advertises scopes.
supported = prm && prm["scopes_supported"]
return supported.join(" ") if supported.is_a?(Array) && !supported.empty? return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
return @provider.scope if @provider.scope && !@provider.scope.empty? return @provider.scope if @provider.scope && !@provider.scope.empty?
@ -948,6 +1167,31 @@ module MCP
@provider.authorization_flow @provider.authorization_flow
end 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:) def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"] authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint unless authorization_endpoint
@ -997,9 +1241,12 @@ module MCP
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
end end
# Submits a form-encoded request to the token endpoint, applying # Submits a form-encoded token request using the authentication method
# the client authentication method advertised in `client_information` and # stored in `client_information`. The method determines whether client
# adding `client_id` (and `client_secret` when not using HTTP Basic). # 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.
def post_to_token_endpoint(as_metadata:, client_info:, form:) def post_to_token_endpoint(as_metadata:, client_info:, form:)
client_id = client_info_required_value(client_info, "client_id") client_id = client_info_required_value(client_info, "client_id")
unless client_id unless client_id
@ -1009,13 +1256,15 @@ module MCP
client_secret = client_info_required_value(client_info, "client_secret") client_secret = client_info_required_value(client_info, "client_secret")
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
form = provider_token_request_params.merge(form)
form = if token_endpoint_auth_method == "private_key_jwt" # Apply one client authentication method per request (RFC 6749 Section 2.3).
# RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of headers = {}
# the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046). form = case token_endpoint_auth_method
# The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is when "private_key_jwt"
# omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity). # The assertion identifies the client through its `iss` and `sub`
# The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated. # 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.
unless @provider.respond_to?(:client_assertion) unless @provider.respond_to?(:client_assertion)
raise AuthorizationError, raise AuthorizationError,
"token_endpoint_auth_method is private_key_jwt but the provider does not " \ "token_endpoint_auth_method is private_key_jwt but the provider does not " \
@ -1026,22 +1275,24 @@ module MCP
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE, "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]), "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 else
form.merge("client_id" => client_id) if client_secret && token_endpoint_auth_method != "none"
end # 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);
headers = {} # omit it because some servers treat it alongside Basic as a second
if client_secret # authentication method and reject the request with `invalid_request`.
case token_endpoint_auth_method
when "client_secret_post"
form["client_secret"] = client_secret
when "none"
# Public client; no credential.
else
# 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) headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
form
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)
end end
end end
@ -1059,11 +1310,7 @@ module MCP
end end
if response.status < 200 || response.status >= 300 if response.status < 200 || response.status >= 300
if token_endpoint_error_code(response) == "invalid_grant" raise token_endpoint_error(response)
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
end
raise AuthorizationError, "Token endpoint returned status #{response.status}."
end end
parsed = begin parsed = begin
@ -1084,17 +1331,41 @@ module MCP
parsed parsed
end end
# Extracts the `error` code from an RFC 6749 §5.2 error response body # Surface only RFC 6749 §5.2 diagnostic fields, never the raw response,
# when one is parseable. Returns nil on any parse failure or when # which may contain tokens or other credentials. Classify the original
# the body is not JSON. # code so sanitization cannot turn malformed input into invalid_grant.
def token_endpoint_error_code(response) def token_endpoint_error(response)
body = response_body_string(response).to_s message = "Token endpoint returned status #{response.status}."
return if body.empty? error_class = AuthorizationError
parsed = JSON.parse(response_body_string(response))
parsed = {} unless parsed.is_a?(Hash)
parsed = JSON.parse(body) error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
parsed["error"] if parsed.is_a?(Hash) error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
rescue JSON::ParserError description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
nil 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)
end end
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
@ -1162,22 +1433,18 @@ module MCP
@http_client ||= @http_client_factory.call @http_client ||= @http_client_factory.call
end end
# Deliberately built without redirect-following middleware. Every destination check in # A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
# this class runs against the URL as written, before the request goes out, so a connection # `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
# that transparently followed a `3xx` would let a server reach a host the checks just refused. # `build_http_client`; `bounded_request` caps its responses all the same.
# 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 def default_http_client
require "faraday" self.class.build_http_client(provider_http_client_customizer)
end
Faraday.new do |faraday| # `nil` for a provider that predates the hook or leaves it unset.
faraday.headers["Accept"] = "application/json" def provider_http_client_customizer
end return unless @provider.respond_to?(:http_client_customizer)
@provider.http_client_customizer
end end
def response_body_string(response) def response_body_string(response)

View file

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

View file

@ -48,6 +48,13 @@ module MCP
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword: # 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 # 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`. # 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 class Provider
include StorageBackedProvider include StorageBackedProvider
@ -87,7 +94,9 @@ module MCP
scope: nil, scope: nil,
storage: nil, storage: nil,
client_id_metadata_document_url: nil, client_id_metadata_document_url: nil,
authorization_request_validator: nil authorization_request_validator: nil,
token_request_params: nil,
http_client_customizer: nil
) )
unless Discovery.secure_url?(redirect_uri) unless Discovery.secure_url?(redirect_uri)
raise InsecureRedirectURIError, raise InsecureRedirectURIError,
@ -109,6 +118,8 @@ module MCP
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`." "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
end end
http_client_customizer = validated_http_client_customizer(http_client_customizer)
@client_metadata = client_metadata @client_metadata = client_metadata
@redirect_uri = redirect_uri @redirect_uri = redirect_uri
@redirect_handler = redirect_handler @redirect_handler = redirect_handler
@ -117,6 +128,8 @@ module MCP
@storage = storage || InMemoryStorage.new @storage = storage || InMemoryStorage.new
@client_id_metadata_document_url = client_id_metadata_document_url @client_id_metadata_document_url = client_id_metadata_document_url
@authorization_request_validator = authorization_request_validator @authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@http_client_customizer = http_client_customizer
end end
# Identifies the OAuth flow this provider drives. # Identifies the OAuth flow this provider drives.

View file

@ -4,11 +4,11 @@ module MCP
class Client class Client
module OAuth module OAuth
# Shared token/credential persistence for the OAuth provider classes # Shared token/credential persistence for the OAuth provider classes
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider` # (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
# for the client_credentials flow). The two grants differ in how they authenticate, # for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
# but both read and write the same two pieces of state through a `storage` object: # The grants differ in how they authenticate, but all read and write the same two pieces of state
# the token response and the client information. This module supplies that delegation # through a `storage` object: the token response and the client information. This module supplies
# so the `Flow` orchestrator can treat any provider uniformly. # that delegation so the `Flow` orchestrator can treat any provider uniformly.
# #
# Including classes must set `@storage` to an object responding to `tokens`, # Including classes must set `@storage` to an object responding to `tokens`,
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
@ -21,6 +21,20 @@ module MCP
# MCP SDK has today. # MCP SDK has today.
attr_reader :authorization_request_validator 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 def access_token
tokens&.dig("access_token") || tokens&.dig(:access_token) tokens&.dig("access_token") || tokens&.dig(:access_token)
end end
@ -44,6 +58,29 @@ module MCP
def clear_tokens! def clear_tokens!
@storage.save_tokens(nil) @storage.save_tokens(nil)
end 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 end
end end

View file

@ -1,13 +1,35 @@
# frozen_string_literal: true # frozen_string_literal: true
module MCP 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 class Icon
SUPPORTED_THEMES = ["light", "dark"].freeze SUPPORTED_THEMES = ["light", "dark"].freeze
attr_reader :mime_type, :sizes, :src, :theme attr_reader :mime_type, :sizes, :src, :theme
def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil) def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme) 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
@mime_type = mime_type @mime_type = mime_type
@sizes = sizes @sizes = sizes
@ -18,5 +40,16 @@ module MCP
def to_h def to_h
{ mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact { mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact
end 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
end end

View file

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

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module MCP module MCP
VERSION = "1.5.0" VERSION = "1.5.1"
end end

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