Commit graph ruby-sdk/docs/_client
Author SHA1 Message Date
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
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
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
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
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
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
Koichi ITO
6d272df0e2
Merge pull request #541 from koic/respond_to_server_pings_in_the_client
Answer server-to-client pings in the client
2026-09-05 02:23:10 +09:00
Koichi ITO
088f4b99e4 Answer server-to-client pings in the client
## Motivation and Context

The ping utility says the receiver "MUST respond promptly with an empty response",
and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor
that on the client side (the TypeScript protocol layer installs an automatic pong,
and the Python session answers `PingRequest` with an empty result), but this client answered
a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio,
leaving long-lived legacy sessions vulnerable to a liveness-checking server.

Over HTTP, an unhandled `ping` is now answered with the empty result in `dispatch_server_request`.
The default deliberately does not live in `@server_request_handlers`: that registry's `any?` opens
the GET listening stream on connect, and a built-in pong must not change listener behavior -
a server can only ping on a stream that is already open. A handler registered via
`on_server_request("ping")` still wins.

Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response.
Frames are told apart by shape rather than id: one carrying `method` is a request or notification,
never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered -
and a request frame can no longer be returned to the caller as if it were the response.
Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading,
which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay
unsupported as documented.

The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written
over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading,
and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright -
is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found,
exactly as before this change.

The modern lifecycle removes `ping` altogether (SEP-2575), so this applies to handshake-lifecycle connections only.

## How Has This Been Tested?

New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with
the ping's id, and a user-registered `"ping"` handler overrides the default; over stdio, a ping injected while
a response is awaited is answered on stdin and the original response still returns. Both fail without the change
(Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over
a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio
and answered with Method not found over HTTP. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None. A server ping that previously received Method not found (or nothing) now receives the empty result
the specification requires.
2026-09-03 16:55:06 +09:00
Koichi ITO
09d4bf41aa Let the embedding application refuse an authorization request
## Motivation and Context

An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers`
in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported`
or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity.

No origin rule can settle this, because an authorization server is legitimately a different origin
from the resource it protects. Validating that a token was issued for the intended audience is a responsibility
the specification places on MCP servers rather than on clients, and there is no portable way for a client
to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor
the `resource` parameter or to report an audience in the token response.

Two things follow from that, and they are separate.

The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows
which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing
the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse.
It is one object rather than keyword arguments so that later revisions of the specification can add to it
without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it
runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`:
a refusal must not leave this client registered at an authorization server the host just rejected.

The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers
the authorization server on every call, so the server named for a session can differ from the one
the stored tokens came from. SEP-2352 already binds client credentials to their issuer,
but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL,
which is portable across authorization servers. The issuer that minted the tokens is now recorded with them,
and `refresh!` refuses to present them to a different one. The transport answers that refusal by running
a full authorization, which brings the new authorization server to the validator above. Tokens stored before
this carry no issuer and keep refreshing.

The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way,
because the specification requires a client to treat the scopes in the challenge as authoritative for the operation,
and narrowing them silently would break that.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises
before any registration request is sent and before the redirect handler runs, and that the `client_credentials`
and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against
a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb`
drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees
the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of
this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens,
and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected
until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer
it authorizes as before.
2026-08-31 02:33:10 +09:00
Koichi ITO
c9d36b04bf [Doc] Align the Docs Page Names With the MCP Specification
Three pages carried names the 2026-07-28 specification does not use: "Multi-Round-Trip Results" for
the pattern the specification titles Multi Round-Trip Requests (MRTR) and serves at basic/patterns/mrtr -
the results are the `InputRequiredResult` wire type, not the feature - plus "Completions" for the utility
the specification titles Completion, and "Notification Subscriptions" for the pattern it titles Subscriptions.
Retitle the three pages to the specification's names, move them to /server/mrtr/ and /client/mrtr/,
/server/completion/, and /server/subscriptions/ following the specification's slugs, update every internal reference,
and keep the URLs published with v1.3.0 working via jekyll-redirect-from.
The Discovery page keeps its title but moves from /server/discovery/ to /server/discover/, matching
the specification's slug and the `server/discover` RPC, with its published URL redirected the same way.
2026-08-22 18:07:38 +09:00
Koichi ITO
88831b46f6 Move the Documentation From README.md to the Documentation Site
Move the documentation that had grown to 3000+ lines in README.md onto https://ruby.sdk.modelcontextprotocol.io
and slim the README down to a quick start and feature overview, following the Python SDK layout:

- Add 21 server pages as the docs/_server/ collection (overview plus transports, discovery, tools, prompts, resources,
  roots, sampling, elicitation, multi round-trip results, notifications, notification subscriptions, cancellation, progress,
  ping, completions, logging, pagination, server context, configuration, and custom methods), 8 client pages as
  the docs/_client/ collection (overview, transports, lifecycle, multi round-trip results, cancellation, ping, pagination,
  and authorization), and 3 extension pages as the docs/_extensions/ collection (overview, capability extensions, and MCP Apps,
  following the Extensions Overview recommendation that SDK documentation list the supported extensions) -
  one page per topic, ordered to match the sidebar of the 2026-07-28 specification, with the client-side APIs
  (pinging, cancelling, and paginating from MCP::Client) documented under Building Clients
- Add top-level Examples and Protocol Versions pages after Installation, linking the runnable examples in examples/
  and summarizing the supported protocol versions, the era model, and client negotiation
- Render the three sections as just-the-docs collections, which list every page beneath a plain category heading in
  the sidebar with no folding, and serve every page at an extensionless URL under /server/, /client/, and /extensions/,
  matching the sibling SDK documentation sites
- Replace docs/building-servers.md and docs/building-clients.md with redirects to the new section overview pages via
  jekyll-redirect-from, redirect the previously published /installation.html to its extensionless URL, fold their content
  that was missing from README.md into the new pages, and update the Tool argument keys reference comment in lib/mcp/server.rb to
  the relocated Tools page
- Reduce README.md to the badges, a compact feature overview in the Python SDK style, installation instructions, a stdio
  server and client quick start, and a License section, using absolute URLs for the remaining repository links
  so they resolve on rubygems.org
- Adapt formatting where GitHub rendering habits break on the site: convert the numbered "three ways to define" lists into headings,
  since kramdown restarts numbering at 1 when code blocks split list items, convert GitHub-style alerts into just-the-docs callouts
  with the SEP-2260 server-to-client association note raised to a red warning, merge the duplicated Exception Reporting
  and Configuration Block Data sections into the Configuration page, and relocate the Streamable HTTP settings that were nested under
  the Logging section into the Transports page
- Refresh the migrated content against the current implementation: correct stale claims and broken samples the README carried,
  note on each session-era feature how it relates to the modern lifecycle of MCP 2026-07-28, and point spec links at the latest revision,
  keeping deliberate 2025-11-25 pins for pages the modern revision removed
- Style the site after the Rails API documentation palette (red links and accents on neutral surfaces) with matching
  light and dark color schemes, center the sidebar and content as one block, add a Previous/Next footer pager following
  the sidebar order, open external links in a new tab, and serve the MCP logo as the favicon

Every code block and heading from the previous README was verified to have a home in the new docs pages or the slimmed README before
the reduction; a few samples were corrected rather than copied, as noted above.
2026-08-21 13:24:04 +09:00