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
Koichi ITO
8a24e50d1b
Merge pull request #543 from koic/release_1_5_0
Release 1.5.0
2026-09-05 11:58:59 +09:00
Koichi ITO
33525d6494 Release 1.5.0
This release makes the client answer a server's `ping` with the empty result the specification requires:
earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio,
so a server that checks liveness dropped their long-lived sessions. On the OAuth side,
the embedding application can refuse an authorization request through `authorization_request_validator`,
and stored tokens are refreshed only against the authorization server that issued them.

The validator is what makes this a minor release. The refresh check is the one behavior change:
when the authorization server named for a session differs from the one that issued the stored tokens,
the client reauthorizes instead of refreshing, and tokens stored by earlier releases, which carry no issuer,
keep refreshing.
2026-09-05 02:36:44 +09:00
Koichi ITO
f706cc8a5e
Merge pull request #542 from koic/fix_flaky_listen_keepalive_thread_assertion
Fix the flaky thread count assertion in the listen keepalive test
2026-09-05 02:23:29 +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
eeab784fc8
Merge pull request #539 from koic/add_authorization_request_validator
Let the embedding application refuse an authorization request
2026-09-05 02:22:47 +09:00
Koichi ITO
09c310341b Fix the flaky thread count assertion in the listen keepalive test
## Motivation and Context

"listen keepalive is not started when the interval is nil" fails intermittently on CI. The most recent run failed
on Ruby 3.2 alone while 3.1 and head passed, and a re-run of the same commit went green.

The assertion samples a process-wide count:

```ruby
before = Thread.list.size
open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
assert_equal before, Thread.list.size, "a nil interval must not spawn a keepalive thread"
```

`Thread.list` covers the whole process, so the count moves for reasons this test has no interest in.
The observed failure reported 54 against 51: three threads had *gone away* between the samples, not appeared.
This file starts threads in sixteen places, and the SSE stream and reaper threads they leave behind finish whenever
they finish, so whether the count holds still depends on the test order and on the scheduler rather than on
the behavior under test.

The test itself is asking the right question. `setup` builds its transport with `listen_keepalive_interval: nil`
precisely so opening a listen stream spawns no timer, and the point is to hold that guarantee. Only the measurement is wrong,
so this compares the set of threads instead: a thread that disappears no longer registers, and a thread that appears still does.
Minitest runs these tests serially and `open_listen_stream` drives the SSE body inline rather than on a thread,
so a keepalive timer is the only thread that can appear across the two samples.

No library code changes.

## How Has This Been Tested?

The rewritten assertion was checked for sensitivity before being kept: a temporary probe pointed `open_listen_stream` at
a transport built with `listen_keepalive_interval: 15` and asserted the set difference was *not* empty. It passed,
confirming the assertion still catches a spawned keepalive thread rather than merely never failing. The probe was
removed afterwards.

The failure did not reproduce locally beforehand, across six seeds of the file on its own and six full-suite runs,
which matches a timing-sensitive flake that needs a slower machine.

## Breaking Changes

None.
2026-09-04 00:19:38 +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
6b1704f330
Merge pull request #540 from koic/mark_deprecated_features_on_the_server_overview
[Doc] Mark the deprecated features on the server overview
2026-09-02 01:16:15 +09:00
Koichi ITO
6f0b85486b [Doc] Mark the deprecated features on the server overview
The overview's Key Features and Supported Methods listed `roots`, `sampling`, and `logging/setLevel` with no deprecation marking,
while their own pages carry the SEP-2577 warnings, presenting a current-looking feature surface to a reader skimming the overview.
The five entries now carry the pages' "deprecated as of 2026-07-28" wording; `elicitation/create` stays unmarked - it is not deprecated,
and the modern lifecycle keeps carrying it as requests embedded per the multi round-trip requests pattern.
2026-09-01 01:14:59 +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
0cd1514bde
Merge pull request #538 from koic/namespace_the_conformance_rake_tasks
Group the conformance Rake tasks under a `conformance` namespace
2026-08-31 01:29:29 +09:00
Koichi ITO
920707cd7e Group the conformance Rake tasks under a conformance namespace
## Motivation and Context

The three conformance tasks were named `conformance`, `conformance_list`, and `conformance_server`,
spelling out with underscores the grouping that Rake expresses with namespaces.

They are now `conformance:test`, `conformance:list`, and `conformance:server`.
The bare `conformance` task is kept as an alias for `conformance:test`.

## How Has This Been Tested?

By running `rake -T` to confirm the three tasks are grouped under `conformance:`
with the alias listed beside them, `rake --dry-run default` to confirm that the default task
resolves `conformance:test`, and `rake conformance:list` to exercise a renamed task end to end.

## Breaking Changes

None. These are development-only Rake tasks.
2026-08-30 02:24:52 +09:00
Koichi ITO
11f9aacaa9
Merge pull request #537 from koic/update_examples_for_2026_07_28
Update examples for the 2026-07-28 era
2026-08-29 18:55:38 +09:00
Koichi ITO
bfc6e551a3 Update examples for the 2026-07-28 era
## Motivation and Context

The runnable examples drifted from the SDK they ship with:

- POST requests whose `Accept` header does not cover both `application/json` and `text/event-stream` are answered 406,
  so the cURL walkthroughs  no longer worked as printed.
- `MCP::Client#connect` now defaults to `mode: :auto`, which adopts the sessionless modern lifecycle (2026-07-28)
  against a dual-era server; the Streamable HTTP client example requires a session and broke against its own server.
- POST responses on an established session arrive as an SSE stream, which the hand-rolled HTTP client did not parse.
- `notification_tool` claimed to send SSE notifications but only returned a `Tool::Response`.
- `http_client.rb` read a resource URI the server never registered and skipped `notifications/initialized`.

Handshake examples now offer the latest handshake protocol version (`2025-11-25`) instead of `2024-11-05`,
the Streamable HTTP client pins `mode: :legacy` for its session-based demo, `notification_tool` reports
real `notifications/progress` events requested through `_meta.progressToken`, and the interactive client streams
those events from the POST response so they are visible on screen.

The modern lifecycle itself had no runnable example, so a new server and client pair (`modern_http_server.rb`,
`modern_http_client.rb`) shows the sessionless flow end to end: `server/discover` capability discovery,
the per-request `_meta` envelope with the `Mcp-Method` and `Mcp-Name` headers (stamped by the SDK client automatically),
`resultType` stamping, SEP-2549 cache hints, a SEP-2322 multi round-trip `deploy` tool resumed automatically by
the client's elicitation handler, and the removal of legacy-only methods such as `ping`. The same server keeps serving
the legacy `initialize` flow, routed per request by the `MCP-Protocol-Version` header. `examples/README.md` documents
the pair and adds a cURL walkthrough covering the modern headers, the envelope, and the multi round-trip exchange.

## How Has This Been Tested?

- `ruby examples/stdio_client.rb` against `stdio_server.rb`
- `ruby examples/http_client.rb` against `http_server.rb`
- `ruby examples/streamable_http_client.rb` against `streamable_http_server.rb`, including the progress stream
- `ruby examples/modern_http_client.rb` against `modern_http_server.rb` (discover, modern adoption, cache hints,
  `resultType`, the multi round-trip resume, `ping` rejection)
- The cURL walkthroughs in the banners and `examples/README.md`, including the `requestState` echo and a legacy `initialize`
  against the modern example server

## Breaking Changes

None.
2026-08-28 12:03:52 +09:00
Koichi ITO
da4ce54075
Merge pull request #536 from koic/release_1_4_0 2026-08-28 10:14:09 +09:00
Koichi ITO
50accf644d Release 1.4.0
This release repairs the `subscriptions/listen` stream: a host that buffers its responses,
the documented Rails controller pattern among them, can decline the method with
`serve_subscriptions_listen: false` instead of answering a modern client with a streaming body
it cannot render, and the stream's ordering guarantees are enforced - no notification before
the acknowledgement, none after the graceful closing result.

The new keyword argument is what makes this a minor release; everything else is a bug fix.
The controller-pattern crash is a regression from 1.2.0, where the modern lifecycle began
answering `subscriptions/listen` that earlier versions had refused as an unknown method,
and it fails quietly: only the notification stream breaks, so tool calls keep working.
2026-08-28 03:54:46 +09:00
Koichi ITO
6b6b641655
Merge pull request #535 from koic/serialize_listen_stream_writes_on_teardown
Serialize `subscriptions/listen` writes so the graceful result is the final message
2026-08-27 01:12:33 +09:00
Koichi ITO
7909600d16
Merge pull request #534 from koic/add_a_docs_preview_rake_task
Add a `rake docs:preview` task serving the docs site locally
2026-08-27 01:11:26 +09:00
Koichi ITO
2200cbd2bb Serialize subscriptions/listen writes so the graceful result is the final message
## Motivation and Context

On graceful teardown (`transport.close`), each open `subscriptions/listen` stream receives
its `SubscriptionsListenResult` response before closing, signaling a clean end the client can distinguish
from an abrupt disconnect. But a delivery that snapshotted the stream from the registry
before teardown cleared it performs its write outside the lock, so a change notification could land after the final result.

Each subscription entry now carries a per-stream write mutex. Teardown marks the entry closed and writes
the result under that mutex; a racing delivery either lands before the result or observes `closed` and skips,
keeping the graceful result the stream's final message. The mutex is per stream and held only around the single write call,
so deliveries to different streams stay as parallel as before, and no path nests it inside the transport mutex
(delivery snapshots release `@mutex` first, and the write-error cleanup takes `@mutex` only after the write mutex is released),
ruling out lock-order inversions. Keepalive pings stay outside the mutex: they are SSE comment frames, which clients ignore by
specification, so one landing after the result is harmless.

## How Has This Been Tested?

With a new regression test pinning the race (an entry captured before teardown and re-presented to delivery afterwards writes
nothing after the result), the full suite, RuboCop, and the conformance suite, all green.

## Breaking Changes

None. The write mutex is internal to the listen registry; the wire only gains the ordering guarantee.
2026-08-26 04:44:02 +09:00
Koichi ITO
411f6b9f0f
Merge pull request #532 from koic/activate_listen_streams_after_the_acknowledgement
Deliver `subscriptions/listen` notifications only after the acknowledgement
2026-08-26 04:39:24 +09:00
Koichi ITO
636e494068 Add a rake docs:preview task serving the docs site locally
## Motivation and Context

The documentation site under `docs/` had no development command: previewing a change meant hand-assembling
a Jekyll environment, since the site only builds for real when a release publishes it through
`bin/generate-gh-pages.sh` and the GitHub Pages runtime. `rake docs:preview` now builds and serves
the site at http://localhost:4000 (override with `PORT`), with watch-based rebuilds on edit.

The preview dependencies live in their own `docs/Gemfile` rather than the gem's Gemfile:
`github-pages` mirrors the runtime that builds the released site (jekyll-remote-theme,
jekyll-redirect-from, and the Jekyll version Pages actually runs), which keeps the preview faithful
but is far too heavy to impose on the gem itself. The task subshells into that bundle with
`Bundler.with_unbundled_env`, the same delegation shape the conformance tasks use for npx.
Two support pieces keep the pinned toolchain running on current Ruby. `docs/_preview/taint_shim.rb` restores
the taint API as no-ops: Liquid 4.0.3 calls `obj.tainted?` on every variable render,
ahead of its own lax-mode check (`liquid/variable.rb:124`), so on Ruby 3.2 and later,
where the taint API was removed, the first layout render dies with NoMethodError, while on Ruby 3.1
and earlier the methods still exist as deprecated no-ops and the shim is inert. The shim is loaded only into
the preview process via RUBYOPT. The former default gems the pinned Jekyll expects are listed in the docs Gemfile.
The task also regenerates `docs/_data/versions.yml` from the version tags, mirroring what `bin/generate-gh-pages.sh`
produces at deploy time, so the nav footer shows the released-gem version line exactly as it does on the published site.
Build products and the generated data file are ignored.

## How Has This Been Tested?

By running the task on a spare port and verifying the served pages, the footer's version line,
and that a SIGINT stops it cleanly with exit 0; `git status` stays clean while it runs (all build products ignored).
Verified on both Ruby 3.1.5 (shim inert) and Ruby 4.0.6, where a shimless build reproduces the NoMethodError
and the task runs green with the shim. RuboCop and the full suite are green.

## Breaking Changes

None. The task and its Gemfile are development-only additions; the gem and its dependencies are untouched.
2026-08-25 12:36:54 +09:00
Koichi ITO
3b79c8e58a
Merge pull request #533 from koic/make_subscriptions_listen_refusable
Make `subscriptions/listen` refusable for hosts that cannot stream
2026-08-25 12:26:25 +09:00
Koichi ITO
3da0c10bed Make subscriptions/listen refusable for hosts that cannot stream
## Motivation and Context

The documented Rails controller pattern builds a fresh transport per request and renders `body.first`,
but a modern client's `subscriptions/listen` made `handle_request` return the streaming `Proc` body,
so exactly the documented configuration answered every notification-stream request with a 500 - quietly,
since tool calls and listings kept working. Nothing gated the route: `serves_subscriptions_listen?` was
hardcoded `true` and consulted only by `Server#discover` for capability stripping,
and the modern path deliberately ignores `stateless:`.

`StreamableHTTPTransport.new` now accepts `serve_subscriptions_listen:` (default `true`, behavior unchanged).
When `false`, the transport skips the listen interception so the method falls through the dispatcher as
unimplemented - the spec's 404 with JSON-RPC `-32601`, in an Array body the controller pattern can render - and
`serves_subscriptions_listen?` reflects the setting, so `Server#discover` stops advertising the `listChanged`/`subscribe`
capability flags through its existing wiring and the advertisement agrees with the actual behavior.
The documented controller example passes the flag and explains why; refusing implicitly in `stateless:` mode
was rejected because a stateless transport mounted as a long-lived Rack app serves listen streams correctly
(listen delivery deliberately precedes the stateless notification guard).

On the default streaming side, the listen body is now a small streaming-body object rather than a bare `Proc`:
it still responds to `call` and deliberately not to `each` (Rack would otherwise classify it as enumerable
and break the streaming), while `first` raises an error naming `serve_subscriptions_listen: false`
and pointing at the transports documentation, so a host that buffers the body the documented way fails with
guidance instead of a bare `NoMethodError`.

Fixes #531.

## How Has This Been Tested?

With the issue's reproduction script (default: a streaming body whose `body.first` raises the guidance;
with the flag: 404, `application/json`, and an Array body whose first element is the `-32601` error),
new transport tests covering the refusal shape, the capability stripping on `server/discover`, and the guidance
raise with the body's streaming classification, the full suite, RuboCop, and the conformance suite, all green;
the docs build passes every internal link and anchor check.

## Breaking Changes

None. The keyword defaults to the current behavior; refusal is opt-in.
2026-08-25 02:53:08 +09:00
Koichi ITO
5ce308b082 Deliver subscriptions/listen notifications only after the acknowledgement
## Motivation and Context

Per SEP-2575, the server MUST NOT send any notification on a subscription before `notifications/subscriptions/acknowledged`.
The listen stream was registered in the delivery registry before its acknowledgement was written (the write happens outside
the lock so a stalled reader cannot block the transport), so a change notification published concurrently from another thread
could be written to the stream ahead of the acknowledgement.

The entry is now inserted inactive - still reserving the id and the cap slot atomically - and becomes eligible for delivery only
after the acknowledgement write has completed. A concurrent notification inside the window skips the inactive entry,
which is indistinguishable from the change happening just before the subscription opened.

## How Has This Been Tested?

With a new regression test pinning the window (an entry injected in the registered-but-unacknowledged state receives nothing until activation),
the full suite, RuboCop, and the conformance suite, all green.

## Breaking Changes

None. Activation happens synchronously inside the same response body proc, so an acknowledged stream behaves exactly as before.
2026-08-25 00:29:52 +09:00
Koichi ITO
9cd1041e7c
Merge pull request #528 from koic/replace_the_nav_footer_theme_attribution
[Doc] Replace the theme attribution in the nav footer
2026-08-24 17:38:37 +09:00
Koichi ITO
8866317e62 [Doc] Replace the theme attribution in the nav footer
The bottom of the sidebar (and the mobile page footer) showed the theme's fallback line
"This site uses Just the Docs, a documentation theme for Jekyll", which tells readers nothing about the SDK.
Overriding nav_footer_custom.html replaces it with the release the documentation describes, linking the RubyGems
page of the version deployed via _data/versions.yml by bin/generate-gh-pages.sh, and with the licensing:
the documentation is CC-BY-4.0 per LICENSE, while the code licensing is delegated to LICENSE itself,
whose consent-based MIT to Apache-2.0 transition does not fit a footer phrase.
Without versions data (local previews of the docs/ source) only the license sentences render.
2026-08-24 16:45:55 +09:00
Koichi ITO
2a31e0bd80
Merge pull request #526 from koic/align_roadmap_deprecation_wording
[Doc] Align the ROADMAP deprecation wording with the shipped behavior
2026-08-24 00:46:53 +09:00
Koichi ITO
500d247e68
Merge pull request #525 from koic/align_docs_page_names_with_the_spec
[Doc] Align the Docs Page Names With the MCP Specification
2026-08-24 00:46:24 +09:00
Koichi ITO
c934b04f48 [Doc] Align the ROADMAP deprecation wording with the shipped behavior
## Motivation and Context

The Deprecated Features paragraph still described the pre-#516 design, claiming the SDK
"emits deprecation warnings when they are used with protocol version 2026-07-28 or newer".
Since #516 the only deprecation warning is the client-side one, emitted on a modern connect
that declares the Roots or Sampling capabilities, exactly as the 1.2.0 CHANGELOG entry states.
Logging never warns (`notify_log_message` is the sanctioned SEP-2575 delivery path on that wire),
and the server never warns either: `initialize` counter-offers a legacy version,
a modern `Configuration` pin is rejected at construction, and the modern lifecycle refuses
the deprecated server-to-client request APIs outright rather than serving them with a warning.
The paragraph now describes the shipped behavior and says where each feature lives per lifecycle:
unchanged on legacy, carried on 2026-07-28 by input requests embedded in `InputRequiredResult` responses
(the SEP-2322 multi round-trip requests pattern) and the envelope's `logLevel`. Those carriers are not
the SEP-2577 replacements: the spec's deprecated-features registry points migrating implementations
outside the protocol (tool parameters or configuration, direct LLM provider APIs, stderr or OpenTelemetry),
and the paragraph now says so instead of presenting the modern delivery forms as the replacements.

## How Has This Been Tested?

Documentation-only change; proofread against the `ProtocolDeprecations` module header and
the CHANGELOG entry that shipped this behavior in 1.2.0, which the 1.3.0 release leaves unchanged.
2026-08-23 01:15:20 +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
fcb1ac935d
Merge pull request #524 from koic/release_1_3_0
Release 1.3.0
2026-08-22 04:48:27 +09:00
Koichi ITO
79d89e0ac6
Merge pull request #523 from koic/restructure_readme_into_docs_site
Move the Documentation From README.md to the Documentation Site
2026-08-22 04:46:21 +09:00
Koichi ITO
833e21a959 Release 1.3.0
This release adds a `resources_list_handler` so `resources/list` can be served per-connection,
passes handler-returned `_meta` through subscription results, and moves the user-facing documentation
from README.md to the documentation site at https://ruby.sdk.modelcontextprotocol.io.

Two entries under "Changed" are incompatible with 1.2.0 and ship in a minor release under
the exceptions in VERSIONING.md: OAuth endpoint responses larger than 4 MiB are refused,
and a request whose id is already in flight on the same session is answered with Invalid Request.

IMPORTANT: The documentation moves with #523, and the site deploys only through the release workflow,
so #523 needs to be merged immediately before this release.
2026-08-22 01:20:22 +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
Koichi ITO
5124cb452d
Merge pull request #522 from koic/update_mrtr_resumption_doc
[Doc] Describe the client's automatic MRTR resumption
2026-08-21 00:11:26 +09:00
Koichi ITO
0ff1406efe
Merge pull request #510 from koic/pass_meta_through_subscription_results
Pass a handler-returned `_meta` through the subscribe result
2026-08-20 16:06:22 +09:00
Koichi ITO
77f571bc5d [Doc] Describe the client's automatic MRTR resumption
The Multi-Round-Trip Results section still said "automatic resumption is not implemented yet",
but the client driver shipped in #500: with a handler registered through `on_elicitation`,
`on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, and `read_resource` methods fulfill
embedded `inputRequests` and re-issue the original request automatically,
raising `MCP::Client::InputRequiredError` only when no matching handler exists.
Rewrite the paragraph to describe the implemented behavior, including the `input_required_max_rounds` cap
and the keywords for manual driving.
2026-08-20 14:59:40 +09:00
Koichi ITO
34473282b7
Merge pull request #509 from koic/add_resources_list_handler
Add a `resources_list_handler` for context-dependent resource lists
2026-08-20 01:15:27 +09:00
Koichi ITO
4eb66155e2
Merge pull request #521 from koic/reject_duplicate_in_flight_request_ids
Reject duplicate in-flight JSON-RPC request IDs
2026-08-19 01:03:06 +09:00
Koichi ITO
87ad91d170
Merge pull request #520 from koic/bound_oauth_response_bodies
Bound OAuth response bodies in the client
2026-08-18 03:23:11 +09:00
Koichi ITO
a53e8d9ed4 Reject duplicate in-flight JSON-RPC request IDs
## Motivation and Context

A request id is the only key that routes request-scoped messages back to the request that caused them:
progress and log notifications, server-to-client requests such as sampling and elicitation,
and `notifications/cancelled` all resolve through it. Nothing checked that the id was free,
so a second request arriving under an id already in flight took the routing entry over.
From that point the first request's notifications were written to the second request's SSE stream,
and whichever finished first removed the shared entry, leaving the survivor's messages nowhere to go.

The spec puts the uniqueness obligation on the sender, and 2026-07-28 draws it at exactly
the window this change enforces: "The request ID MUST NOT match the ID of any other request the sender
has issued and not yet received a response for". Earlier revisions worded it as never reusing an id within
the session. Neither says what a receiver does with a duplicate, but the Streamable HTTP rules require
that messages the server sends before the response "SHOULD relate to the originating client request",
and two live requests sharing one id make that impossible to honor for either of them.
Answering the second one is the only option that stays correct, so it is refused the same way
a duplicate `initialize` is, and for the same reason: a repeated id must not silently displace state
the first one established.

The reference SDKs currently accept the duplicate and let the newer request take the entry.
That is not a settled design: the Python SDK carries a TODO naming rejection with `INVALID_REQUEST`
as the revisit, so this moves toward where they expect to end up rather than away from them.

`ServerSession#register_in_flight` now claims the id atomically and reports a collision instead of overwriting,
which `Server` turns into an Invalid Request for every transport. `StreamableHTTPTransport` refuses
the colliding POST with 409 before it registers a stream, since the stream is registered ahead of dispatch
and would otherwise take over the routing entry for as long as the rejection takes.

Removal is now guarded by identity everywhere the registry is keyed by request id, so a request that loses
a race cannot retire a registration it does not own. `initialize` never registers, so its `ensure` no longer unregisters:
a refused duplicate `initialize` reusing an in-flight id used to evict that registration on the way out,
silently disabling cancellation for the request that owned it (Streamable HTTP refuses such a POST before dispatch;
stdio reached this path). `drop_broken_stream` had a sharper form of the same bug: it removed whichever stream held
the id while closing the one passed in, which are not necessarily the same stream.

Only ids that are in flight are refused, which is the scope the current spec draws.
Enforcing the older "never within the session" wording would instead mean remembering every id a session ever used,
and the in-flight window is the part routing depends on either way. Sequential reuse keeps working,
and neither this SDK's client (`SecureRandom.uuid`) nor a client built on the TypeScript SDK (a per-connection counter)
can produce a collision.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` reproduce the collision:
a tool parked mid-request, a second POST reusing its id, and assertions that the second POST is refused
and that the first request still receives the progress frame it emits afterwards. Others cover
the identity-guarded removal and confirm that an id can be reused once the earlier request has finished.
New tests in `test/mcp/server_cancellation_test.rb` cover the registry directly, including
the transport-independent Invalid Request and the survival of an in-flight registration across
a refused duplicate `initialize` under the same id. All of them except the sequential-reuse guard fail
without this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

A request whose id is already in flight on the same session is now answered with Invalid Request
(HTTP 409 on Streamable HTTP) instead of being processed. A client that reuses ids concurrently was
already violating the specification and was already having its notifications misrouted.
2026-08-17 03:47:35 +09:00
Koichi ITO
02db089038 Bound OAuth response bodies in the client
## Motivation and Context

`MCP::Client::HTTP` caps a single message from the server at 4 MiB, but the OAuth flow talks to
authorization endpoints over its own Faraday connection, and that one had no cap. Discovery,
dynamic client registration, and token responses were buffered in full and handed to `JSON.parse`,
so a server that answered an OAuth request with an endless body grew the client's memory for as
long as it kept sending.

`Flow` and `IDJAGTokenExchange` each build their own connection, so both needed the bound.
The new `BoundedBody` counts bytes in a Faraday `on_data` callback and refuses the response once
it passes `MAX_RESPONSE_BYTES`, which matches the 4 MiB of `MCP::Client::HTTP::MAX_MESSAGE_BYTES`
and `MCP::Client::Stdio::MAX_LINE_BYTES`. It hands back the status paired with the bounded body
rather than the Faraday response, so no later caller can reach the unbounded `response.body`.

Those bytes are counted after decompression. The default Net::HTTP adapter negotiates `Accept-Encoding`
itself  and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body that expands past
the cap is refused partway through the expansion. That holds only while the connection leaves
`Accept-Encoding` alone, which is now recorded in a comment on both `default_http_client` definitions.

An over-cap response is refused rather than truncated: a partial discovery or token document cannot be validated.
Protected resource metadata is the one place where refusing is not fatal, since any PRM discovery failure
already selects the legacy authorization path by design.

## How Has This Been Tested?

New unit tests in `test/mcp/client/oauth/bounded_body_test.rb` cover the streaming path,
the at-limit boundary, and the fallback for adapters that ignore `on_data`. One of them serves
a gzip body over a real socket rather than through WebMock, which returns a stubbed body verbatim
and never runs the inflater; it fails if the cap ever starts measuring compressed bytes. New tests in
`test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/id_jag_token_exchange_test.rb` drive each of
the four request wrappers end to end with an over-cap body; all five fail without the cap in place.

## Breaking Changes

An OAuth endpoint response larger than 4 MiB is now rejected instead of parsed.
2026-08-16 03:29:15 +09:00
Koichi ITO
ad73154697
Merge pull request #519 from koic/update_roadmap_for_2026_07_28
Refresh ROADMAP.md for the shipped 2026-07-28 support
2026-08-15 12:16:22 +09:00
Koichi ITO
8a886639fd Refresh ROADMAP.md for the shipped 2026-07-28 support
## Motivation and Context

The roadmap predates the 2026-07-28 implementation work and has drifted from the released SDK.
The 2026-08-15 SEP-1730 tier audit flagged the drift while evaluating the roadmap requirement:

- "Current Status" claims only 2025-06-18 and 2025-11-25 support, but the SDK now completes
  the 2026-07-28 revision and passes all scored server and client conformance scenarios for each
  revision's requirement set.
- The SEP-2575 stateless lifecycle rewrite was described as reserved for 2.0,
  but it shipped during 1.x with both lifecycle eras served side by side;
  the few incompatible adjustments shipped in a minor release under the spec-conformance
  and security exceptions described in VERSIONING.md, as recorded in the 1.2.0 CHANGELOG entry.
- Deprecation warnings for Roots/Sampling/Logging (SEP-2577) were described as future work
  but have shipped (#406, #516).
- The documentation section undersold the coverage: all implemented non-experimental features are
  documented with examples (#472, #515).

The refreshed text records the implemented spec revisions and how the 2026-07-28 revision actually shipped,
and keeps the tasks extension (SEP-2663, #391) and the removal of legacy-era support as the remaining tracked items.
Gem versions are deliberately not named: the roadmap describes the current state of the SDK,
and the version-to-revision mapping already lives in CHANGELOG.md.

## How Has This Been Tested?

Documentation-only change; no code paths are affected. Statements were cross-checked against the v1.2.0 CHANGELOG entry
and the 2026-08-15 tier audit results (server 67/67 and client 50/50 scored conformance across the 2025-11-25
and 2026-07-28 requirement sets).

## Breaking Changes

None. This only updates ROADMAP.md.
2026-08-15 12:13:25 +09:00
Koichi ITO
8c2e89afd5 Pass a handler-returned _meta through the subscribe result
## Motivation and Context

`resources/subscribe` and `resources/unsubscribe` answered with a hardcoded `{}`, discarding whatever
the registered handler returned. A server had no way to attach anything to the response - not even `_meta`,
which the specification allows on every result. The handler docstring justified the discard as "always
an empty result `{}` per the MCP specification", but the spec does not require an empty object here:
the subscribe result is an `EmptyResult`, and every result may carry `_meta`.

The one field that is interoperable is `_meta`. The TypeScript SDK validates the subscribe result against
`EmptyResultSchema.strict()`, which rejects any member other than `_meta`, so a top-level field like
a bespoke `subscriptionId` would be refused by a TypeScript client. The Python SDK passes the handler's `EmptyResult`
return through, `_meta` included. Passing `_meta` matches both while staying within what the spec defines.

The subscribe and unsubscribe handlers now pass a returned `_meta` hash through to the result and drop anything else.
`subscription_result` reads `_meta` under a symbol or string key and includes it only when it is itself
a hash; every other return shape - a non-hash, or a hash without a usable `_meta` - keeps the empty `{}` result it
produced before. Advisory data such as a subscription identifier goes under `_meta`, namespaced, rather than
at the top level. This affects only 2025-11-25 and earlier connections, since `resources/subscribe`
and `resources/unsubscribe` are removed from the modern lifecycle.

Fixes #508.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` cover a `_meta` hash passing through on both `resources/subscribe`
and `resources/unsubscribe`, a non-`_meta` field being dropped, a string `_meta` key working, a non-hash `_meta`
being ignored, and a non-hash return keeping the empty result. The existing subscribe and unsubscribe tests,
which return `{}` and assert an empty result, are unchanged.

## Breaking Changes

None. A handler that does not return a hash carrying a `_meta` hash - which is every handler written against
the previous "return value is ignored" contract - still produces the empty `{}` result.
2026-08-13 01:36:15 +09:00
Koichi ITO
27044b0047 Add a resources_list_handler for context-dependent resource lists
## Motivation and Context

`resources/read` can be taken over with `resources_read_handler`, but `resources/list` had no equivalent:
it was bound to the internal `list_resources`, which paginates the constructor-provided `resources` array,
and `define_custom_method` refuses to rebind a method the server already handles.
An application whose visible resources depend on the request - authenticated versus anonymous sessions,
or an OAuth-scoped connector that must see a scope-filtered set - had to build a fresh `Server` per request
or override a private method.

Both reference SDKs let the list vary per request. The TypeScript SDK exposes `resources/list` through
`setRequestHandler` and gives resource templates a per-context `listCallback(ctx)`; the Python SDK takes
an `on_list_resources` handler. This adds the Ruby equivalent.

`resources_list_handler(&block)` registers a block that returns the resource collection to serve.
It feeds `list_resources` rather than replacing it, so the framework still paginates the returned array
and stamps the SEP-2549 cache hints; the block returns only the array, matching how `resources_read_handler` returns
only the contents and the framework wraps them. A block that declares a `server_context:` keyword receives
an `MCP::ServerContext`, reusing the same opt-in rule the read handler uses, so the list can be filtered by
the authenticated principal or granted scope. The block is invoked once per page and must return
a stable ordering across the pages of one query, since the cursor is a positional offset. When no handler is set,
the constructor-provided resources are served unchanged.

The `resources` capability is advertised whether or not the constructor array is populated, so a server that
provides only a `resources_list_handler` still answers `resources/list`.

Fixes #507.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` cover a handler replacing the served collection, a handler receiving `server_context:`
and returning different sets for authenticated and anonymous requests, pagination and cache hints still applying to
the handler-provided array, and a server with no constructor-provided resources served entirely by the handler.
The existing `resources/list` test, which exercises the no-handler default path, is unchanged.

## Breaking Changes

None. When no `resources_list_handler` is set, `resources/list` serves the constructor-provided resources exactly as before.
2026-08-13 00:21:41 +09:00
99 changed files with 9602 additions and 3799 deletions

View file

@ -26,4 +26,4 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '24' # Specify the latest Node.js version.
- run: bundle exec rake conformance
- run: bundle exec rake conformance:test

4
.gitignore vendored
View file

@ -9,3 +9,7 @@
/spec/reports/
/tmp/
Gemfile.lock
docs/_site/
docs/.jekyll-cache/
docs/.jekyll-metadata
docs/_data/versions.yml

View file

@ -15,7 +15,9 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
- `bundle install` - Install dependencies
- `rake test` - Run all tests
- `rake rubocop` - Run linter
- `rake` - Run tests and linting (default task)
- `rake` - Run linting, tests, and the conformance suite (default task)
- `bundle exec rake conformance:test` - Run the MCP conformance suite (see conformance/README.md)
- `bundle exec rake docs:preview` - Serve the documentation site locally at http://localhost:4000 (PORT to override)
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
- `gem build mcp.gemspec` - Build the gem
@ -34,6 +36,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
- Keep dependencies minimal
- Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only
## Documentation
- User-facing documentation lives in `docs/`, one page per topic, published at https://ruby.sdk.modelcontextprotocol.io (deploys only when a release is published)
- Pages live in the `docs/_server/`, `docs/_client/`, and `docs/_extensions/` collections
- Keep README.md slim: quick start and pointers only; document features on the relevant docs page
- Internal links are absolute and extensionless (e.g. `/server/tools/`); front matter is followed by a blank line before the h1
- Callout tiers: `.note` (supplementary), `.important` (spec constraints), `.warning` (deprecated features)
## Commit message conventions
- Use conventional commit format when possible

View file

@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.5.1] - 2026-09-09
This release keeps the HTTP client working with json 3.0. That release accepts the options of `JSON.parse`
as keywords only, and Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash,
which Ruby 3 no longer converts, so every JSON body the client received failed as an internal error.
The client now parses response bodies itself, the way it already handled streamed ones, and no longer
registers the middleware. A malformed JSON body delivered whole by an adapter without streaming support
now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did.
### Fixed
- Parse HTTP client response bodies without Faraday's JSON middleware, which json 3.0 breaks (#546)
## [1.5.0] - 2026-09-05
This release makes the client answer a server's `ping` with the empty result the specification requires.
Earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio,
so a server that checks liveness dropped their long-lived sessions. On the OAuth side,
the embedding application can refuse an authorization request through `authorization_request_validator`,
and stored tokens are refreshed only against the authorization server that issued them:
when the server named for a session differs, the client reauthorizes instead of refreshing,
and the validator sees the newly named server. Tokens stored by earlier releases carry no issuer and keep refreshing.
### Added
- Add `authorization_request_validator` to let the embedding application refuse an authorization request (#539)
### Fixed
- Answer server-to-client pings in the client (#541)
## [1.4.0] - 2026-08-28
This release repairs the `subscriptions/listen` stream. A host that buffers its responses,
the documented Rails controller pattern among them, can decline the method with
`serve_subscriptions_listen: false` instead of answering a modern client with a streaming body
it cannot render, and the stream's ordering guarantees are enforced: no notification reaches
a client before the acknowledgement, and none follows the graceful closing result.
### Added
- Add `serve_subscriptions_listen:` for hosts that cannot hold an SSE stream open (#533)
### Fixed
- Deliver `subscriptions/listen` notifications only after the acknowledgement (#532)
- Serialize `subscriptions/listen` writes so the graceful result is the final message (#535)
## [1.3.0] - 2026-08-22
User-facing documentation now lives on the documentation site at https://ruby.sdk.modelcontextprotocol.io,
and README.md keeps the quick start. Two entries under "Changed" reject traffic that earlier releases accepted
and ship in a minor release under the exceptions described in [VERSIONING.md](VERSIONING.md).
### Added
- Add a `resources_list_handler` for context-dependent resource lists (#509)
- Pass a handler-returned `_meta` through the subscribe result (#510)
### Changed
- Bound OAuth response bodies in the client (#520)
- Reject duplicate in-flight JSON-RPC request ids (#521)
- Move the documentation from README.md to the documentation site (#523)
## [1.2.0] - 2026-08-15
This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with

2979
README.md

File diff suppressed because it is too large Load diff

View file

@ -5,21 +5,29 @@ It is a living document and will evolve as the SDK matures.
## Current Status
The SDK implements the 2025-06-18 and 2025-11-25 spec revisions, and passes all server and client conformance scenarios.
The SDK implements the 2025-06-18, 2025-11-25, and 2026-07-28 spec revisions, and passes all scored server
and client conformance scenarios for each revision's requirement set.
Optional protocol extensions (such as DPoP, workload identity federation, and the tasks extension)
are not yet implemented; SEP-1730 does not require extensions for any tier.
## API Stability
The 1.0.0 release will mark the public API as stable: breaking changes then ship only in major releases,
The 1.0.0 release marked the public API as stable: breaking changes ship only in major releases,
apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md),
which also defines the Semantic Versioning scheme and breaking-change policy.
## Deprecated Features
The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577).
These features remain fully supported throughout 1.x, and deprecation warnings will be added in a future minor release.
Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet:
whether 2.0.0 removes them depends on how future MCP spec revisions treat these features
and on adoption of their replacements.
These features remain supported throughout 1.x: on the legacy lifecycle they work unchanged
(the deprecations do not apply there), and the 2026-07-28 lifecycle carries them through its own mechanisms:
roots-shaped and sampling-shaped input requests embedded in `InputRequiredResult` responses
(the SEP-2322 multi round-trip requests pattern) and per-request log delivery through the envelope's `logLevel`.
The replacements SEP-2577 names live outside the protocol: tool parameters or configuration for roots,
direct LLM provider APIs for sampling, and stderr or OpenTelemetry for logging.
The client emits a deprecation warning when a modern connect declares the deprecated Roots or Sampling capabilities.
Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: whether 2.0.0 removes
them depends on how future MCP spec revisions treat these features and on adoption of their replacements.
## Conformance
@ -28,12 +36,16 @@ Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provide
## Documentation
Reference documentation covers all core features.
Reference documentation covers all non-experimental features with examples.
## Tracking New Spec Revisions
The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per
release based on feature complexity.
Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests,
and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision,
such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0.
The 2026-07-28 revision shipped during 1.x: the server serves both lifecycle eras side by side,
so the SEP-2575 stateless lifecycle (including `server/discover` and `subscriptions/listen`),
SEP-2322 multi round-trip requests, SEP-2549 cache hints, and SEP-2243 custom headers are available alongside
the legacy handshake. The few incompatible adjustments the revision required shipped in
a minor release under the spec-conformance and security exceptions described in [VERSIONING.md](VERSIONING.md).
Removing legacy-era support is reserved for a future major release.
The tasks extension (SEP-2663) remains planned ([#391](https://github.com/modelcontextprotocol/ruby-sdk/issues/391)).

View file

@ -14,41 +14,85 @@ require "rubocop/rake_task"
RuboCop::RakeTask.new
task default: [:rubocop, :test, :conformance]
task default: [:rubocop, :test, "conformance:test"]
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
task :conformance do |t|
next unless npx_available?(t.name)
namespace :conformance do
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
task :test do |t|
next unless npx_available?(t.name)
require_relative "conformance/server_runner"
require_relative "conformance/client_runner"
require_relative "conformance/server_runner"
require_relative "conformance/client_runner"
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
options[:verbose] = true if ENV["VERBOSE"]
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
options[:verbose] = true if ENV["VERBOSE"]
Conformance::ServerRunner.new(**options).run
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
Conformance::ServerRunner.new(**options).run
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
end
desc "List available conformance scenarios"
task :list do |t|
next unless npx_available?(t.name)
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
end
desc "Start the conformance server (PORT)"
task :server do
require_relative "conformance/server"
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
Conformance::Server.new(**options).start
end
end
desc "List available conformance scenarios"
task :conformance_list do |t|
next unless npx_available?(t.name)
# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance`
# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without
# running anything. This keeps the name pointing at the suite instead of at that silent no-op.
desc "Alias for conformance:test"
task conformance: "conformance:test"
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
namespace :docs do
desc "Serve the documentation site locally at http://localhost:4000 (PORT)"
task :preview do
docs_dir = File.expand_path("docs", __dir__)
generate_docs_versions_data(docs_dir)
env = {
"BUNDLE_GEMFILE" => File.join(docs_dir, "Gemfile"),
"RUBYOPT" => "-r#{File.join(docs_dir, "_preview", "taint_shim.rb")}",
}
port = ENV.fetch("PORT", "4000")
Bundler.with_unbundled_env do
system(env, "bundle", "install", "--quiet", chdir: docs_dir, exception: true)
system(env, "bundle", "exec", "jekyll", "serve", "--port", port, chdir: docs_dir, exception: true)
rescue Interrupt
# Ctrl-C is the way to stop the preview, not an error.
end
end
end
desc "Start the conformance server (PORT)"
task :conformance_server do
require_relative "conformance/server"
# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from
# the version tags at deploy time, and the preview generates the same data so the nav footer
# shows the released-gem version line.
def generate_docs_versions_data(docs_dir)
versions = %x(git tag --list).split("\n").filter_map { |tag|
tag[/\A[^0-9]*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\z/, 1]
}.sort_by { |version|
Gem::Version.new(version)
}.reverse
return if versions.empty?
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
Conformance::Server.new(**options).start
mkdir_p(File.join(docs_dir, "_data"))
File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join)
end
def npx_available?(task_name)

View file

@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
### Run all scenarios
```bash
bundle exec rake conformance
bundle exec rake conformance:test
```
Runs both server and client conformance tests in sequence. Server conformance starts the
@ -31,20 +31,20 @@ are allowed to fail without affecting the exit code.
```bash
# Run a single server scenario
bundle exec rake conformance SCENARIO=ping
bundle exec rake conformance:test SCENARIO=ping
# Use a different port with verbose output
bundle exec rake conformance PORT=3000 VERBOSE=1
bundle exec rake conformance:test PORT=3000 VERBOSE=1
# Start the server on a specific port
bundle exec rake conformance_server PORT=3000
bundle exec rake conformance:server PORT=3000
```
### Start the server and test separately
```bash
# Terminal 1: start the server
bundle exec rake conformance_server
bundle exec rake conformance:server
# Terminal 2: run all scenarios
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done.
### List available scenarios
```bash
bundle exec rake conformance_list
bundle exec rake conformance:list
```
Prints all scenario names that can be passed to `SCENARIO`.
@ -76,7 +76,7 @@ repository with the conformance server running:
```bash
# Terminal 1 (this repository): start the conformance server
bundle exec rake conformance_server
bundle exec rake conformance:server
# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
@ -102,4 +102,4 @@ conformance/
Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
fail without affecting the exit code and are tracked to catch regressions.
These are shown in the output of `bundle exec rake conformance`.
These are shown in the output of `bundle exec rake conformance:test`.

15
docs/Gemfile Normal file
View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
# Dependencies for the local docs preview (`rake docs:preview`), kept out of the gem's own Gemfile:
# github-pages mirrors the GitHub Pages runtime that builds the released site
# (jekyll-remote-theme, jekyll-redirect-from, and the Jekyll version Pages actually runs).
source "https://rubygems.org"
gem "github-pages", group: :jekyll_plugins
gem "webrick"
# Former default gems that the Jekyll version pinned by github-pages still requires on Ruby 4.0.
gem "base64"
gem "bigdecimal"
gem "csv"
gem "logger"

View file

@ -0,0 +1,405 @@
---
layout: default
title: Authorization
nav_order: 8
---
# Authorization
Authorization is handled by the transport layer. This page covers authenticating the HTTP transport,
from custom headers such as bearer tokens to the OAuth 2.1 flows the SDK implements
(PKCE with dynamic client registration, the client credentials grant, and cross-app access).
## HTTP Authorization
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:
```ruby
http_transport = MCP::Client::HTTP.new(
url: "https://api.example.com/mcp",
headers: {
"Authorization" => "Bearer my_token"
}
)
client = MCP::Client.new(transport: http_transport)
client.tools # will make the call using Bearer auth
```
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
## OAuth 2.1 Authorization
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization),
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`,
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
`ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead;
their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again.
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
```ruby
require "mcp"
provider = MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "My MCP App",
redirect_uris: ["http://localhost:3030/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:3030/callback",
redirect_handler: ->(authorization_url) {
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
# or a manual `puts authorization_url` in CLI tools.
},
callback_handler: -> {
# Capture the redirect (for example, by running a small HTTP listener on
# `redirect_uri`) and return [code, state] from the query string.
},
)
transport = MCP::Client::HTTP.new(
url: "https://api.example.com/mcp",
oauth: provider,
)
client = MCP::Client.new(transport: transport)
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
client.tools
```
Required keyword arguments to `Provider.new`:
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
an explicit value always wins.
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form
(with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.
See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client).
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
(portable CIMD `client_id`s are kept). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what
lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members
instead drops these bindings with no error.
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
When the authorization server advertises `client_id_metadata_document_supported: true`,
the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
Defaults to `nil`, which adds nothing. The authorization request is not affected.
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
when the provider is built, and the accepted Hash is copied and frozen.
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
the flow checks the returned value by the same rules and raises the same error before the token request is sent.
{: .warning }
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
> while remaining available for authorization servers that do not support them. Publish a CIMD document and set `client_id_metadata_document_url`; the SDK then prefers it
> automatically wherever the authorization server advertises support.
To persist credentials across restarts, supply your own storage:
```ruby
class FileTokenStorage
def initialize(path)
@path = path
end
def tokens
read["tokens"]
end
def save_tokens(value)
write("tokens" => value)
end
def client_information
read["client"]
end
def save_client_information(value)
write("client" => value)
end
private
def read
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
end
def write(updates)
File.write(@path, JSON.dump(read.merge(updates)))
end
end
provider = MCP::Client::OAuth::Provider.new(
# ... required keywords ...
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
)
```
### Token Endpoint Errors
When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and
the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2).
For example:
```text
Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods
```
The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string
diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers.
An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token
recovery behavior is unchanged.
Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when
truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed.
The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors.
Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging
and redaction policy before persisting them or displaying them to users.
### Client Credentials Grant
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant is not expected to issue a refresh token (RFC 6749 Section 4.4.3);
a refresh token the authorization server issues anyway is used on the next `401`.
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
```
Keyword arguments:
- `client_id`: Required. `client_secret`: Required with the secret-based methods; the grant is
for confidential clients, so a credential is mandatory.
- `token_endpoint_auth_method`: `"client_secret_basic"` (default), `"client_secret_post"`,
or `"private_key_jwt"` (RFC 7523 JWT client assertion per SEP-1046). `"none"` is rejected
with `ClientCredentialsProvider::InvalidCredentialsError`.
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
### Cross-App Access (JWT Bearer) Grant
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again.
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
an enterprise secret store or a test double without changing the transport wiring.
```ruby
provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
client_id: "my-mcp-client",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
assertion_provider: ->(audience:, resource:) {
MCP::Client::OAuth::IDJAGTokenExchange.request(
token_endpoint: "https://idp.example.com/token",
id_token: ENV.fetch("IDP_ID_TOKEN"),
client_id: "my-idp-client",
audience: audience,
resource: resource,
)
},
# scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
```
Keyword arguments:
- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server.
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
### Customizing the OAuth HTTP Client
The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery,
dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own,
not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server.
To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider:
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder },
)
```
The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL,
and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection.
It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently;
today it runs once per authorization attempt, but that is not a promise.
A few constraints follow from the checks described below:
- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send
to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError`
before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)`
that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off.
- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off.
- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request
and to turn a token endpoint error into `Flow::InvalidGrantError`.
- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives.
- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens,
and the access tokens in token responses; redact them before they reach a log.
### Communication Security
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
whether the swap was triggered by
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
### Discovery URL Destinations
The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations.
Both checks run before the request is sent, and neither is configurable.
- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server,
so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728,
which does not require it.
- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be
IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7).
The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`,
and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name.
- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act,
and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working.
The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed,
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request
goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)`
replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against
the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused.
A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself.
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.
Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through
`http_client_factory:` is bounded as well, so there is no way to opt out of it.
### Reviewing the authorization request
The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want
your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes
it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects,
so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on
MCP servers rather than on clients.
`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so:
```ruby
ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"]
provider = MCP::Client::OAuth::Provider.new(
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
authorization_request_validator: ->(request) {
ALLOWED_ISSUERS.include?(request.authorization_server)
},
)
```
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither
the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification
can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide
(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes.
`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized
(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one,
the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce
a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured.
Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path,
and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising.
The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it:
```ruby
ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] }
provider = MCP::Client::OAuth::Provider.new(
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
authorization_request_validator: ->(request) {
ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) }
},
)
```
A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them.
It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential,
or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened,
and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server
the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected.
Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it,
while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure.
The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification
requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for,
which is what every MCP SDK does today.
It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for.
An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to
present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is.
The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse.
Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization.

View file

@ -0,0 +1,71 @@
---
layout: default
title: Cancellation
nav_order: 5
---
# Cancellation
`MCP::Client` lets the caller cancel a request it has already issued,
per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation).
The recommended pattern is to pass
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
the server, and the calling thread is woken up with `MCP::CancelledError`:
```ruby
client = MCP::Client.new(transport: transport)
cancellation = MCP::Cancellation.new
Thread.new do
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
rescue MCP::CancelledError
# cleanup
end
# Later, from another thread:
cancellation.cancel(reason: "user pressed cancel")
```
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword.
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
{: .note }
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
{: .note }
> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight
> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests
> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still
> raises `MCP::CancelledError` - but the server runs the request to completion.
## Wire-order guarantees
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
so the server is guaranteed to read the request line before the cancel line.
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)),
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
## Custom transports
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
## Server Side
How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page.

47
docs/_client/index.md Normal file
View file

@ -0,0 +1,47 @@
---
layout: default
title: Overview
nav_order: 1
permalink: /client/
redirect_from:
- /building-clients.html
- /building-clients/
---
# Building an MCP Client
The `MCP::Client` class provides an interface for interacting with MCP servers.
This class supports:
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
when the server serves it; see [Lifecycle](/client/lifecycle/)
- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery)
- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings;
see [Answering Server Pings](/client/ping/#answering-server-pings)
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completion](/server/completion/)
- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`,
or `on_roots` handlers are registered; see [Multi Round-Trip Requests](/client/mrtr/)
- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/)
- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with
the `max_pages` guard; see [Pagination](/client/pagination/)
- Automatic JSON-RPC 2.0 message formatting
- UUID request ID generation
Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics.
Authorization is handled by the transport layer; see [Authorization](/client/authorization/).
## Tool Objects
The client provides a wrapper class for tools returned by the server:
- `MCP::Client::Tool` - Represents a single tool with its metadata
This class provides easy access to tool properties like name, description, input schema, and output schema.

78
docs/_client/lifecycle.md Normal file
View file

@ -0,0 +1,78 @@
---
layout: default
title: Lifecycle
nav_order: 3
---
# Lifecycle
Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake
on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28.
This page covers `MCP::Client#connect` and how it negotiates between the two.
## Handshake
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
```ruby
client.connect
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
client.connected? # => true
client.server_info # => cached InitializeResult
```
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page.
By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake
only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it.
## Lifecycle Negotiation
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discover/) first and adopts
the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to
the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare
a `mode:` keyword always receive the classic call shape, unchanged.
```ruby
client.connect # negotiate automatically (default)
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
client.connect(mode: :legacy) # force the classic initialize handshake
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe
```
Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process)
and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on
the legacy lifecycle.
Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result,
their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`,
top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`)
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
```ruby
client.protocol_version # negotiated or adopted version, either lifecycle
client.server_capabilities # capabilities Hash, either lifecycle
client.instructions # instructions text, either lifecycle
client.server_implementation # server name/version; nil when a modern server does not identify itself
```
Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded,
the server now serves the modern lifecycle and the automatic negotiation adopted it.
Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
## Explicit Discovery
`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery
that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct
exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and
the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discover/) page
for the wire shapes.
```ruby
result = client.discover
result.supported_versions # => ["2026-07-28"]
```

62
docs/_client/mrtr.md Normal file
View file

@ -0,0 +1,62 @@
---
layout: default
title: Multi Round-Trip Requests
nav_order: 4
redirect_from:
- /client/multi-round-trip-results/
---
# Multi Round-Trip Requests
MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`,
or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
## Automatic Driving
The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`,
and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds`
(10 by default, matching the TypeScript and Python SDKs).
```ruby
client = MCP::Client.new(transport: transport)
client.connect(capabilities: { elicitation: { form: {} } })
client.on_elicitation do |params|
{ action: "accept", content: { name: "Alice" } }
end
# The input_required round trips are driven automatically; this returns the final result.
response = client.call_tool(name: "collect_name", arguments: {})
```
Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds
the client declared.
## Manual Driving
Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final;
the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords:
```ruby
begin
client.call_tool(name: "collect_name", arguments: {})
rescue MCP::Client::InputRequiredError => error
answers = error.input_requests.transform_values { |request| answer_for(request) }
client.call_tool(
name: "collect_name",
arguments: {},
input_responses: answers,
request_state: error.request_state,
)
end
```
`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility.
Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged.
## Server Side
Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment
shim that serves pre-2026 clients are documented on the server [Multi Round-Trip Requests](/server/mrtr/) page.

View file

@ -0,0 +1,80 @@
---
layout: default
title: Pagination
nav_order: 7
---
# Pagination
Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses
per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination).
Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it.
## Iterating Pages
`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection.
The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor
as method accessors; a `meta` accessor also mirrors the response's `_meta` field:
```ruby
client = MCP::Client.new(transport: transport)
cursor = nil
loop do
page = client.list_tools(cursor: cursor)
page.tools.each { |tool| process(tool) }
cursor = page.next_cursor
break unless cursor
end
```
The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and
`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page.
Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration:
| Server `page_size` | `client.list_tools(cursor: nil)` |
|--------------------|---------------------------------------------------------------------|
| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. |
| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. |
If your application needs the complete collection regardless of how the server is configured, either loop on
`next_cursor` as shown above, or use the whole-collection methods described below.
## Fetching the Complete Collection
`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate
through all pages and return a plain array of items, guaranteeing the full collection regardless
of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round
trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor`
it has already sent, and it stops after `max_pages` pages.
```ruby
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
```
`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods
will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that
point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it
if you legitimately expect more pages than that.
Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
## Cache Hints
Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`)
and whether shared intermediaries may cache it (`cacheScope`); see
[List Result Caching](/server/pagination/#list-result-caching) on the server page for how they are emitted.
On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`:
```ruby
page = client.list_tools
page.ttl_ms # => 60000 (nil when the server sent no hint)
page.cache_scope # => "private"
```
## Server Side
Enabling pagination with `page_size:` is documented on the server [Pagination](/server/pagination/) page.

49
docs/_client/ping.md Normal file
View file

@ -0,0 +1,49 @@
---
layout: default
title: Ping
nav_order: 6
---
# Ping
The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping)
allows either side of the connection to verify that the peer is still responsive.
{: .note }
> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of
> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself
> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle,
> so on a modern connection a conforming server (the Ruby SDK server included) rejects it
> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility.
`MCP::Client` exposes `ping` to send a ping to the server:
```ruby
client = MCP::Client.new(transport: transport)
client.ping # => {} on success
```
`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error.
It raises `MCP::Client::ValidationError` when the response `result` is missing or
is not a Hash (matching the spec requirement that `result` be an object).
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
propagate as exceptions raised by the transport layer.
## Answering Server Pings
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with
the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open
(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings.
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when
the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is
dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:`
on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits.
To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces
the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`,
as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page.
## Server Side
How servers answer `ping` requests and ping the client themselves is documented on
the server [Ping](/server/ping/) page.

285
docs/_client/transports.md Normal file
View file

@ -0,0 +1,285 @@
---
layout: default
title: Transports
nav_order: 2
---
# Transports
`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with.
This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports,
their sessions and server-to-client request handling, and the interface a custom transport must implement.
## Stdio Transport Layer
Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output.
`MCP::Client::Stdio.new` accepts the following keyword arguments:
| Parameter | Required | Description |
|---|---|---|
| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). |
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
Example usage:
```ruby
stdio_transport = MCP::Client::Stdio.new(
command: "bundle",
args: ["exec", "ruby", "path/to/server.rb"],
env: { "API_KEY" => "my_secret_key" },
read_timeout: 30
)
client = MCP::Client.new(transport: stdio_transport)
# Perform the MCP initialization handshake before sending any requests.
client.connect
# List available tools.
tools = client.tools
tools.each do |tool|
puts "Tool: #{tool.name} - #{tool.description}"
end
# Call a specific tool.
response = client.call_tool(
tool: tools.first,
arguments: { message: "Hello, world!" }
)
# Close the transport when done.
stdio_transport.close
```
The stdio transport automatically handles:
- Spawning the server process with `Open3.popen3`
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
- JSON-RPC 2.0 message framing over newline-delimited JSON
- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings)
{: .note }
> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create`
> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer
> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route,
> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation`
> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/).
## HTTP Transport Layer
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser`
to read SSE (`text/event-stream`) responses.
Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both,
so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always
does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well.
`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to
answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode)
serving handshake-lifecycle clients, and only while no listening stream is opened:
```ruby
gem "mcp"
gem "faraday", ">= 2.0"
gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses
```
Example usage:
```ruby
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
client = MCP::Client.new(transport: http_transport)
# Perform the MCP initialization handshake before sending any requests.
client.connect
# List available tools
tools = client.tools
tools.each do |tool|
puts <<~TOOL_INFORMATION
Tool: #{tool.name}
Description: #{tool.description}
Input Schema: #{tool.input_schema}
TOOL_INFORMATION
end
# Call a specific tool
response = client.call_tool(
tool: tools.first,
arguments: { message: "Hello, world!" }
)
# Call a tool with progress tracking.
response = client.call_tool(
tool: tools.first,
arguments: { count: 10 },
progress_token: "my-progress-token"
)
```
The server sends `notifications/progress` during execution; the bundled transports do not currently
expose these notifications to application code, so the token's effect is visible on the server side.
See the [Progress](/server/progress/) page.
`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts,
and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread,
so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes).
The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead,
the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin
the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at.
### Sessions
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
```ruby
http_transport.session_id # => "abc123..."
http_transport.protocol_version # => "2025-11-25"
```
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
```ruby
http_transport.close
```
These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is
no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead.
### Server-to-Client Requests (Elicitation)
Servers can send requests back to the client while one of the client's own requests is in flight - for example,
[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call.
Register a handler and advertise the capability on `connect` to respond to them:
```ruby
client.connect(capabilities: { elicitation: {} })
client.on_elicitation do |params|
{
action: "accept",
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
}
end
```
Registering a handler opens a standalone HTTP GET SSE stream on a background thread
([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)),
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
`http_transport.on_server_request("method/name") { |params| ... }`.
On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same
registered handlers instead drive the requests embedded in `input_required` results,
as documented on [Multi Round-Trip Requests](/client/mrtr/).
### Server-to-Client Requests (Sampling)
Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
letting a server leverage the client's model access without its own API keys.
{: .warning }
> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
> new servers should call LLM provider APIs directly.
Register a handler and advertise the capability on `connect`:
```ruby
client.connect(capabilities: { sampling: {} })
client.on_sampling do |params|
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
{
role: "assistant",
content: { type: "text", text: completion.text },
model: completion.model,
stopReason: "endTurn",
}
end
```
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
```ruby
client.on_sampling do |params|
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
generate_completion(params)
end
```
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
### Custom Headers from Tool Parameters
On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries
an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers per SEP-2243, so intermediaries can route
on the values without parsing bodies. The declarations are learned from `tools/list` responses:
list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values
(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`,
and a `null` or absent argument omits its header.
Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names,
duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from
`tools/list` results on modern connections, with a warning naming the tool.
Legacy connections are unaffected: nothing is learned, mirrored, or excluded.
### Customizing the Faraday Connection
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.
The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter:
```ruby
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
faraday.use MyApp::Middleware::HttpRecorder
faraday.adapter :typhoeus
end
```
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
{: .note }
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
> the SSE streaming callback of another response, re-entering the connection on the same thread.
> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter
> with persistent connections must tolerate that re-entry.
## Custom Transports
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:
```ruby
class CustomTransport
# Sends a JSON-RPC request to the server and returns the raw response.
#
# @param request [Hash] A complete JSON-RPC request object.
# https://www.jsonrpc.org/specification#request_object
# @return [Hash] A hash modeling a JSON-RPC response object.
# https://www.jsonrpc.org/specification#response_object
def send_request(request:)
# Your transport-specific logic here
# - HTTP: POST to endpoint with JSON body
# - WebSocket: Send message over WebSocket
# - stdio: Write to stdout, read from stdin
# - etc.
end
end
client = MCP::Client.new(transport: CustomTransport.new)
```
Custom transports that need to support client-side cancellation have additional requirements;
see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page.

View file

@ -2,18 +2,44 @@
title: "MCP Ruby SDK"
description: "The official Ruby SDK for Model Context Protocol servers and clients."
remote_theme: just-the-docs/just-the-docs
color_scheme: ruby-light
# Include generated files and directories which may start with underscores
include:
- "_*"
plugins:
- jekyll-redirect-from
# Sidebar sections: each collection renders as a plain category heading
# with its pages listed beneath it, instead of a clickable parent page.
collections:
server:
permalink: "/server/:name/"
output: true
client:
permalink: "/client/:name/"
output: true
extensions:
permalink: "/extensions/:name/"
output: true
just_the_docs:
collections:
server:
name: Building Servers
client:
name: Building Clients
extensions:
name: Extensions
# Search
search_enabled: true
# Footer
gh_edit_link: true
gh_edit_link_text: "Edit this page on GitHub."
gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk"
gh_edit_branch: "main"
gh_edit_source: "docs"
gh_edit_view_mode: "edit"
# Callouts
callouts:
note:
title: Note
color: blue
important:
title: Important
color: yellow
warning:
title: Warning
color: red

View file

@ -0,0 +1,35 @@
---
layout: default
title: Capability Extensions
nav_order: 2
---
# Capability Extensions
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
```ruby
capabilities = MCP::Server::Capabilities.new
capabilities.support_tools
capabilities.support_extensions("com.example/feature" => { enabled: true })
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
```
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result,
and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake.
Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations
with envelope-first resolution, on either lifecycle.
On the client, pass extensions through `connect`:
```ruby
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
```

17
docs/_extensions/index.md Normal file
View file

@ -0,0 +1,17 @@
---
layout: default
title: Overview
nav_order: 1
permalink: /extensions/
---
# Extensions
Extensions add optional functionality on top of the core MCP protocol.
Support for an extension is negotiated per connection:
clients and servers declare the extensions they speak under the `extensions` member of their capabilities,
as described on [Capability Extensions](/extensions/capability-extensions/).
The extensions this SDK ships support for:
- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results

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