Commit graph ruby-sdk/lib/mcp/client
Author SHA1 Message Date
Koichi ITO
6d272df0e2
Merge pull request #541 from koic/respond_to_server_pings_in_the_client
Answer server-to-client pings in the client
2026-09-05 02:23:10 +09:00
Koichi ITO
088f4b99e4 Answer server-to-client pings in the client
## Motivation and Context

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

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

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

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

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

## How Has This Been Tested?

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

## Breaking Changes

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

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

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

Two things follow from that, and they are separate.

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

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

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

## How Has This Been Tested?

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

## Breaking Changes

Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens,
and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected
until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer
it authorizes as before.
2026-08-31 02:33:10 +09:00
Koichi ITO
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
a62e56ef48
Merge pull request #516 from koic/counter_offer_legacy_version_on_initialize
Stop negotiating modern protocol versions through the `initialize` handshake
2026-08-15 11:32:15 +09:00
Koichi ITO
84eac104f0
Merge commit from fork
Restrict OAuth Discovery URL Destinations
2026-08-15 11:26:11 +09:00
Koichi ITO
48b7aaacd9 Stop negotiating modern protocol versions through the initialize handshake
## Motivation and Context

Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761

A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through
the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are
both gated on the per-request `_meta` envelope, which such a connection never carries,
so strictly validating clients reject every result as missing the REQUIRED `resultType`.

The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of
the protocol version itself: legacy versions establish a session via `initialize`
(`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with
no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope
is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state
the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates
from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry;
the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too,
and counter-offers `LATEST_HANDSHAKE_VERSION`.

`initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list
(`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know,
is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on
`2025-11-25`, where `resultType` does not exist and its absence is the correct shape,
resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and
the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are
intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is
no longer constructible, so the gates are correct by construction.

The client side is symmetrized the same way, matching both reference SDKs:
the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default,
passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError`
(validated before the stdio transport spawns its child process), and a server answering `initialize` with
a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to
the discovery probe and never downgrades it into the legacy fallback: the probe's failure is
the real answer and propagates, instead of the fallback dying on the handshake guard.
The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`),
and the handshake list is derived from the stable and modern lists so the era partition cannot drift
when the next revision lands.

Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect
on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land
on a deprecating version and the call site the warnings used to sit on had become unreachable.
On the server the answer is removal: `Configuration#protocol_version` scopes the handshake,
so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction.
Accepting one would configure nothing (a modern version has no handshake to pin),
and neither reference SDK carries a config value that is accepted but inert:
Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions`
is consumed by its own era's mechanism. With no path left onto a deprecating revision,
the server-side deprecation warning plumbing became unreachable and is removed along with it.
Keying those warnings on a modern-era session instead was tried and rejected,
because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line
would then carry a deprecation warning for doing exactly what the revision prescribes
(a single test file produced over a thousand of them).

A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered,
and such a request continues to be served at the negotiated revision rather than rejected.
Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule
covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`,
through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with
its consequence, which is that no `resultType` appears on those results.

Two details follow from the pin being handshake-scoped. Reading it back when nothing is set
now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era,
so the getter no longer hands out a value its own writer rejects and round-tripping
the setting keeps working; only `Server#validate!` reads that default,
through three comparisons that answer the same for either value, so nothing else moves.
And the rejection names its reason instead of listing the accepted versions,
because this is the error an upgrade from a release that accepted the value lands on,
which makes it the migration note a bare list would not be.

`MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now,
and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be
a breaking change for no gain.

The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper
now use the handshake version as well, and the README section on `protocol_version` describes the pin as
handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default,
and `2026-07-28` reached through `server/discover` with no configuration needed.

## How Has This Been Tested?

New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is
counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType`
(the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer,
the `ArgumentError` for an explicitly modern version on the legacy connect
(asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult`
on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of
falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.

Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for
clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version,
the answer is an older one, and the connection speaks the answer afterwards
(over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption
from an echo, because every one of them had the server answer with the version the client had just offered.
Both new tests were confirmed to fail when the client is made to report its own offer instead.

Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked:
the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer,
and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires.
The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version,
or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths,
since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests
on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.

The default-pin test now also round-trips what it reads back into a fresh `Configuration`,
which is what the old default could not do. The default move was checked for neutrality outside
the suite as well, by building a server that uses every feature `Server#validate!` gates on that value
(`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations)
and confirming all of them are still accepted.

## Breaking Changes

Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through
the handshake are counter-offered `2025-11-25` and are served at that revision, matching
the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now
raises `ArgumentError` (the pin scopes the handshake, which cannot land there);
the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway.
Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`;
nothing in this SDK behaves differently for either value, but code comparing that reader against
a literal will see the change. The client no longer offers modern versions on the legacy handshake
and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features
no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision;
the client-side warning on a modern connect replaces them.
2026-08-14 16:08:07 +09:00
Koichi ITO
aaf0c71995 Restrict OAuth Discovery URL Destinations
## Motivation and Context

The OAuth client checked the scheme of every discovery URL but never checked where it pointed.
`Discovery.secure_url?` returns true for any `https` URL regardless of host, and the `resource_metadata` URL from
a `WWW-Authenticate` challenge is placed first in the candidate list, so a server answering
`401 Bearer resource_metadata="https://169.254.169.254/..."` had the client issue that request during discovery,
before the user approves anything. The same gap applied one hop deeper: a server that serves its own
Protected Resource Metadata can name any host in `authorization_servers`, and the endpoints in the document fetched
from there receive the dynamic client registration and the token request that carries the client credentials.

Two rules now apply to those URLs. Both run before the request is sent, and neither is configurable.

The `resource_metadata` URL must be on the MCP server's own origin. Protected Resource Metadata describes that server,
so a real deployment publishes it there, and requiring it means a challenge cannot aim the first request of the flow at
an unrelated host. RFC 9728 does not itself require this, so the SDK is deliberately stricter than the specification:
the range check below cannot recognize an internal host that is named rather than addressed, and this is the one URL
a server supplies directly.

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,
which is the precaution RFC 9728 Section 7.7 asks clients to take. That check is skipped when the configured MCP server URL
is itself on such an address. Pointing a client at a private network is a deliberate act and its authorization server
usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network
keep working. Only a server reachable on the public internet is barred from steering the client inward.

A range check is only as good as its reading of the address, so the second rule also refuses hosts written in
the numeric IPv4 grammar `inet_aton` accepts and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal,
octal, or decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and `0x7f000001` all reach
127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, so reading only the dotted-decimal spelling would refuse
`169.254.169.254` while admitting every alias of it. Those hosts are refused outright rather than decoded and
range-checked, because their value is resolver-dependent: a leading zero reads as octal on one platform and decimal on
another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. Choosing a reading here would leave
whichever one was not taken as a way through, and nothing is legitimately addressed in this notation.

Hostnames are not resolved. A lookup here would be a second network request driven by the same untrusted input,
and the address it returned need not be the one the HTTP client connects to a moment later, so the check would read as
a guarantee it cannot make. `MCP::Client::OAuth::Flow` also documents why its Faraday connection has no redirect-following
middleware: every check runs against the URL as written, so a connection that follows a `3xx` on its own would reach hosts
these rules just refused.

## How Has This Been Tested?

- New unit tests in `test/mcp/client/oauth/discovery_test.rb` cover `same_origin?` (case-insensitive scheme and host,
  explicit default ports, differing port/host/scheme, subdomains, malformed and hostless input) and `private_network_host?`
  (every blocked range, IPv4-mapped IPv6 spellings, bracketed IPv6, `localhost` matched exactly, and public addresses and names).
  One test documents that a named internal host reads as public, which is the deliberate limit of the predicate.
- Further unit tests cover the numeric spellings: packed integers, hexadecimal, and short forms that reach a blocked range,
  and the ambiguous leading-zero forms whose value depends on the resolver. Removing the rule leaves them addressable,
  which is what those tests fail on.
- New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/http_oauth_test.rb` drive both rules through `run!`,
  `refresh!`, and the transport. They assert with `assert_not_requested` that no request was issued rather than only that an error
  was raised, since nothing downstream can un-send one. One of them names the authorization server `https://0xa9fea9fe`, which
  without the rule above is fetched. A further test runs a complete flow with the MCP server and the authorization server on
  two loopback ports, which is what the carve-out exists for.
- `rake conformance` passes with all 22 auth scenarios green. Its harness runs the MCP server and the authorization server on
  separate loopback ports and publishes PRM on the server's own origin, so it exercises exactly the shapes these rules must not break.

## Breaking Changes

A client whose MCP server is reachable on the public internet no longer follows a `resource_metadata` URL on a different origin,
and no longer contacts an authorization server or OAuth endpoint at a private IP literal or at a host written in the numeric
IPv4 notation above. All were previously allowed. Local development, private-network deployments, and the conformance suite
are unaffected.
2026-08-13 14:54:27 +09:00
Koichi ITO
248b5f8403 Bound the total wait across SSE reconnection attempts
## Motivation and Context

The SSE `retry:` field is chosen by the server, and `MCP::Client::HTTP` passed it to `sleep` unchanged in
both reconnection paths. `await_response_after_disconnect` runs on the calling thread, so a server that primes
a stream with an event id, closes it gracefully, and asks for a large `retry:` interval keeps that thread waiting
for as long as it likes. `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, not how long
it waits before each attempt, and no I/O timeout covers a sleep.

The obvious fix, clamping `retry:` to a ceiling, is not available: the spec says a client
"MUST respect the `retry` field, waiting the given number of milliseconds before attempting to reconnect",
and reconnecting sooner than a server asked for is also the wrong direction to err in, since `retry:` is how
an overloaded server asks for room. Both reference SDKs pass the value through untouched.

What the spec does allow is waiting longer. The `retry` field it points at is the one WHATWG HTML defines,
whose reconnection algorithm reads "Wait a delay equal to the reconnection time of the event source. Optionally,
wait some more." It also leaves reconnecting at all a SHOULD, which this client already declines once
`MAX_RECONNECTION_ATTEMPTS` is reached. Both fixes below sit inside that room; neither shortens a wait.

`await_response_after_disconnect` now carries a deadline, `max_reconnection_wait:` seconds
(`MAX_RECONNECTION_WAIT`, 300, the same budget as `SSE_LISTENER_READ_TIMEOUT`). Before each attempt it compares
the delay the server asked for against the remaining budget, and stops reconnecting when honoring it in full
would run past the deadline. The server's interval is therefore either waited out exactly or not acted on at all,
and the calling thread is released as soon as the answer is known. On the reported case, a day-long `retry:`,
the thread now returns without sleeping at all.

Whatever is left of the budget also becomes the resumed stream's read timeout, so the bound covers a server
that accepts the GET and then sends nothing. That GET previously relied on whatever the Faraday adapter
defaults to, which is 60 seconds for Net::HTTP but need not exist at all for a caller-supplied adapter.
`listen_for_server_requests` already guarded its own GET this way with `SSE_LISTENER_READ_TIMEOUT`.

The other path has the opposite problem. `listen_for_server_requests` treats a graceful close as success and
resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and the listening stream reconnects
in a tight loop. A `MIN_RECONNECTION_DELAY_MS` floor of 100ms stops that, and is the "optionally, wait some more"
case exactly. That path gets no deadline: it runs on a thread this client owns and is meant to poll indefinitely
(the spec asks clients to "poll" a closed stream by reconnecting), so a long `retry:` there idles the SDK's own
listener rather than the embedding application.

`DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` are unchanged and still match the Python SDK.

## How Has This Been Tested?

New tests in `test/mcp/client/http_test.rb` cover a day-long `retry:` releasing the calling thread without
sleeping, a delay that fits the budget still being waited out in full and resuming normally, a caller-supplied
`max_reconnection_wait:` stopping the resume with a message naming the budget, and the argument validation.
Both reconnection paths are covered for the unusable values a server can send: `retry: 0` is raised to the floor
on the resumption path and on the listening stream, and negative or non-numeric values (`-5000`, `abc`, `1e6`, `500ms`)
fall back to the default delay, since the SSE parser accepts only a run of digits. The existing reconnection tests,
whose `retry:` values fit the default budget, are unaffected.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance `sse-retry` scenario scores
`client-sse-retry-timing`, which checks the reconnect happens neither early nor very late against a 500ms `retry:`;
since no wait is ever shortened, that check is unaffected by the size of the value it uses.

## Breaking Changes

A request whose resumption cannot complete within `max_reconnection_wait:` (300 seconds by default) now fails instead of
waiting however long the server asked for. Servers asking for intervals that fit the budget are unaffected.
Pass a larger `max_reconnection_wait:` if you expect longer.
2026-08-11 05:29:48 +09:00
Koichi ITO
3511375204 Mirror x-mcp-header tool parameters into Mcp-Param-* headers per SEP-2243
## Motivation and Context

SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate `inputSchema` properties with
`x-mcp-header` so intermediaries can route on argument values without parsing bodies: the client MUST
mirror each annotated argument of a `tools/call` into an `Mcp-Param-{Name}` header, encode values that
cannot ride as plain ASCII field values with the `=?base64?...?=` sentinel, and omit the header for
`null` or absent arguments. `MCP::Client::HTTP` sent only the standard `Mcp-Method`/`Mcp-Name` mirror
headers, so every `http-custom-headers` check of the 2026-07-28 conformance requirements fails.

The new `MCP::Client::McpParamHeaders` module ports the TypeScript SDK's `mcpParamHeaders` codec:

- `scan` walks a tool's `inputSchema` for declarations and validates every constraint the spec places
  on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties
  (`number` is accepted alongside the spec's `string`/`integer`/`boolean` because the published
  referee annotates `type: "number"` parameters, the same accommodation the TypeScript SDK makes),
  and static reachability through a chain of `properties` keys only: an annotation under `items`,
  the combinators, or `$defs` invalidates the whole tool definition.
- `build` resolves each declaration's path in the call's `arguments`, omits `null`/absent and
  non-representable values (unsafe integers, non-finite floats, non-primitives), converts primitives
  per the spec's rules, and encodes with the sentinel exactly where a plain ASCII field value
  cannot carry the value (empty, edge whitespace, bytes outside visible ASCII, sentinel-shaped input).

`MCP::Client::HTTP` learns declarations from the `tools/list` responses passing through it (the same
source of truth the TypeScript SDK's response cache uses) and mirrors on each `tools/call` alongside
the existing `Mcp-Method`/`Mcp-Name` construction. Learning and mirroring exist on the modern
lifecycle only, matching both reference SDKs. An invalid tool definition mirrors nothing, per
the spec's guidance to send without custom headers when no reliable declarations are available;
a complete (uncursored, `nextCursor`-less) listing rebuilds the registry so declarations of
unlisted tools stop mirroring (the Python SDK's pruning), and the registry is capped
(`MAX_MCP_PARAM_TOOLS`, 1000) so a server rotating tool names cannot grow it without bound.

On the modern lifecycle, `MCP::Client#list_tools` and `#tools` also enforce the spec's MUST:
a tool definition whose `x-mcp-header` annotations violate the constraints is excluded from
the listing, with a warning naming the tool and the reason (the spec's SHOULD), so one malformed
definition does not block the valid tools - the same filtering the Python SDK's
`_absorb_tool_listing` and the TypeScript SDK's cached-listing finalize apply. A value that cannot
be represented as UTF-8 omits its header like the other non-representable values.
The README documents the mirroring and the exclusion under the lifecycle negotiation section.
The server-side validation half of the codec is a follow-up.

## How Has This Been Tested?

New `test/mcp/client/mcp_param_headers_test.rb` covers the scan constraint matrix (root and
non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive
conversions (including `42.0` serializing as `42` and unsafe integers refusing), the encoding matrix
(plain pass-through, empty, non-ASCII, edge whitespace, CR/LF, sentinel-shaped input), and header
construction with nested paths and omitted values (including a value that cannot be represented
as UTF-8). `test/mcp/client/http_test.rb` drives the transport end to end over a modern connection:
a `tools/list` teaching the declarations, the following `tools/call` carrying the mirrored headers,
an invalid declaration mirroring nothing, a legacy connection learning nothing, and a complete
listing pruning the declarations of unlisted tools. `test/mcp/client_test.rb` covers the MUST-level
exclusion: the invalid definition dropped from `#tools` with the warning on a modern connection,
and listed unchanged on a legacy one. `bundle exec rake` is green.

## Breaking Changes

None. Requests to servers whose tools carry no `x-mcp-header` annotations are byte-for-byte unchanged,
and the new headers only appear for tools that declare them.
2026-08-08 20:58:35 +09:00
Koichi ITO
d0d7308a89 Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575
## Motivation and Context

Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic.

Server side, `Server#discover` reaches its spec-final shape:

- `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
  legacy versions are negotiated via `initialize`, not selected from discovery. This also closes
  an interop hazard of the interim response: auto-negotiating TS and Python clients treat
  any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`,
  so advertising only legacy versions made them fail outright.
- The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints
  on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with
  `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise.
- The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp
  instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002
  (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level
  `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and
  the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built
  against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`.

Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default:

- `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct
  (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`).
  It works before `connect`; the stdio transport's connect guard exempts `server/discover`
  and spawns the subprocess on demand.
- `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs
  (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:`
  (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover`
  and falls back to the legacy handshake on any discovery failure, including a successful discovery
  without a mutually supported modern version (rollout tolerance for servers that answer discovery
  while only serving legacy versions). Custom transports whose `connect` does not declare `mode:`
  keep receiving the historical legacy call shape, detected by keyword introspection;
  a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords
  stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError`
  instead of silently downgrading.
- The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`,
  instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried
  the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make
  the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version,
  the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here
  as `mode: :modern` with `protocol_version: "2026-07-28"`.
- An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe,
  so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle.
  `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools
  and when using server-initiated requests, which exist only on the legacy lifecycle.
- Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends
  on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference:
  `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one),
  `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta`
  `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server
  does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover.
- The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK)
  when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately,
  but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback
  instead of blocking `connect` forever. Regular requests keep the unbounded default.
- In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto
  every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names
  from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and
  keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize`
  is never sent.
- The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake
  and its scenario servers do not expect a preceding probe.

Resolves #389.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values);
the existing discover assertions across `server_test.rb` and both transport test files were updated to
the modern-only `supportedVersions`.

New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError`
on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`,
the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`,
`ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection,
and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence.

New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with
the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks,
and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`,
modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout
default applying only while probing, and the fallback when a server never answers the probe.

## Breaking Changes

`server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`);
this method has not shipped in a gem release yet, so no released behavior changes.

On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before
the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with
an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing
the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation
`protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable
across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
2026-08-08 03:45:00 +09:00
Ates Goral
6a43d3d6fb feat: deprecate roots sampling and logging
Warn when Roots, Sampling, or Logging APIs are used with MCP protocol version 2026-07-28 or newer.

Add a shared protocol deprecation helper, YARD deprecation notes, and regression tests covering warnings at and after the deprecation threshold and no warnings for older or never-established protocol versions.

Closes https://github.com/modelcontextprotocol/ruby-sdk/issues/390

Co-authored-by: Qlaw <noreply@qlaw.quick.shopify.io>
2026-08-02 13:13:10 -04:00
Koichi ITO
5a879b3678
Merge pull request #445 from rickreyhsig-wealthbox/client-tool-annotations
Expose server tool annotations on MCP::Client::Tool
2026-07-28 01:21:53 +09:00
rickreyhsig-wealthbox
3591acdfaa Expose server tool annotations on MCP::Client::Tool
Client#tools/#list_tools built Tool objects without an annotations
field even though the server includes annotations in every
tools/list response. Add the reader and pass the raw hash through,
mirroring how output_schema is already handled.
2026-07-27 09:54:54 -04:00
Koichi ITO
7ebaf989d9
Merge pull request #459 from koic/bound_client_sse_event_buffering
Bound client-side message buffering in the HTTP transport
2026-07-16 01:00:26 +09:00
Koichi ITO
5533a6df92
Merge pull request #454 from koic/cross_app_access
Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant
2026-07-16 00:58:08 +09:00
Koichi ITO
ced5cd1a95 Bound client-side message buffering in the HTTP transport
## Motivation and Context

`MCP::Client::HTTP` buffers server bytes with no upper bound in two places:

- SSE responses are parsed incrementally, but the parser accumulates `data:` lines until
  a blank line dispatches the event, so a server that never terminates an event grows
  the buffer indefinitely. The abort-on-response signal does not help here, since it only fires
  after a complete response event has been dispatched.
- Non-SSE (JSON) bodies accumulate in `SSEStream#buffer` chunk by chunk with no limit.

Both the POST response stream and the standalone GET listening stream are affected.
The stdio client already bounds server frames (`max_line_bytes:`, 4 MiB default)
and the server transports bound request bodies (`max_request_bytes:`, 4 MiB default);
this brings the HTTP client in line.

`MCP::Client::HTTP.new` gains `max_message_bytes:` (default 4 MiB), which caps the bytes buffered
for a single message - an SSE event or a JSON response body. External byte counting cannot tell
consumed-and-discarded bytes (comments, field names, delimiters) from consumed-and-retained ones,
so the check measures the parser's own String buffers after each chunk. An over-limit message raises
`RequestHandlerError` on the request path and stops the GET listening stream instead of reconnecting to
the same over-limit event.

## How Has This Been Tested?

New tests in `test/mcp/client/http_test.rb` cover the SSE and JSON rejection paths end-to-end,
the per-event reset (events that individually fit must not count against later ones),
the env-less `on_data` fallback, and the constructor validation. `bundle exec rake`
(tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

Responses larger than 4 MiB are now rejected by default; pass a larger g`max_message_bytes:` to accept them.
2026-07-15 12:02:15 +09:00
Koichi ITO
ab4075c8c7 Fix RuboCop offenses detected with rubocop-shopify 3.0.0
## Motivation and Context

This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems.
Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer
disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`,
and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense
from the new `Style/ReduceToHash` cop.

This change fixes them as follows:

- Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7,
  where Regexp literals are not frozen.
- Remove inline disable directives that became redundant.
- Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names
  can reference class names such as `NoMethodError`.
- Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test
  since Mocha cannot stub methods on frozen objects.
- Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`.

Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0

## How Has This Been Tested?

`bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The conformance suite also passes against its baseline.

## Breaking Changes

None. Freezing these constants only affects code that mutates them in place, which has never been supported.
2026-07-15 01:46:51 +09:00
Koichi ITO
145a6b6230 Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant
## Motivation and Context

The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990,
accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization:
the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via
RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with
the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`.
The `auth/cross-app-access-complete-flow` conformance scenario exercises the complete flow and previously failed
its two core checks (the Ruby client ran the authorization-code flow instead).

This mirrors the merged TypeScript SDK implementation (`requestJwtAuthorizationGrant` / `CrossAppAccessProvider` in
typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design:

- New `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint
  (`subject_token_type` id_token, `requested_token_type` id-jag, `audience` = the MCP authorization server's issuer,
  `resource` = the canonical MCP server URL) and validates that the response's `issued_token_type` is an ID-JAG
  before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK.
- New `MCP::Client::OAuth::CrossAppAccessProvider` declares `authorization_flow :jwt_bearer` and takes the assertion
  from a callable (`call(audience:, resource:)`), so the common `IDJAGTokenExchange` case, enterprise secret stores,
  and tests all plug in the same way (the TypeScript provider's assertion callback works identically).
  The MCP AS credentials are stored as `client_secret_basic` client information.
- `Flow` dispatches `:jwt_bearer` alongside `:client_credentials`: discovery, issuer validation, and endpoint security checks
  are shared with `run!`, then `run_jwt_bearer!` requests the assertion with the validated issuer as the audience and posts
  the `jwt-bearer` grant through the existing `post_to_token_endpoint`.
- The conformance client builds a `CrossAppAccessProvider` when the harness injects `idp_id_token` (exchanging it at
  `idp_token_endpoint` with `idp_client_id`, like the TypeScript and Python conformance clients),
  and `auth/cross-app-access-complete-flow` is removed from `conformance/expected_failures.yml`.

## How Has This Been Tested?

- New `test/mcp/client/oauth/id_jag_token_exchange_test.rb` asserts the exact RFC 8693 form parameters on the wire
  and the response validation: a non-ID-JAG `issued_token_type` is rejected, as are a missing `access_token`,
  non-2xx responses, unparseable bodies, and non-object JSON.
- New `test/mcp/client/oauth/cross_app_access_provider_test.rb` covers the provider surface:
  stored `client_secret_basic` credentials, the `:jwt_bearer` flow declaration, audience/resource passthrough to
  the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider),
  and storage delegation.
- New tests in `test/mcp/client/oauth/flow_test.rb` run the full grant against WebMock stubs: the token request carries
  `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the assertion, the RFC 8707 `resource`, and HTTP Basic credentials,
  with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as `audience`
  and the canonical server URL as `resource`; an empty assertion aborts before the token endpoint is contacted.

## Breaking Changes

None. `IDJAGTokenExchange` and `CrossAppAccessProvider` are new, and the `Flow` dispatch only activates for providers
that declare `authorization_flow :jwt_bearer`.
2026-07-13 11:45:42 +09:00
Koichi ITO
be59660500
Merge pull request #443 from koic/elicitation_sep1034_defaults
Support client-side elicitation with SEP-1034 schema defaults
2026-07-12 02:18:27 +09:00
Koichi ITO
e1fabe3fcb
Merge pull request #439 from koic/issuer_bound_client_credentials
Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352
2026-07-12 02:16:12 +09:00
Koichi ITO
562afdf71e
Merge pull request #432 from koic/client_credentials_jwt
Support `private_key_jwt` Client Authentication for the client_credentials Grant
2026-07-12 02:14:11 +09:00
Koichi ITO
3d442d0a65
Merge pull request #448 from koic/handle_non_object_json_rpc_messages
Handle non-object JSON-RPC messages without raising
2026-07-12 02:12:46 +09:00
Koichi ITO
8aae052efd
Merge pull request #431 from koic/iss_parameter_validation
Validate the RFC 9207 `iss` Authorization Response Parameter per SEP-2468
2026-07-12 02:11:52 +09:00
Koichi ITO
8875990909 Handle non-object JSON-RPC messages without raising
## Motivation and Context

A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised
on a non-object value parsed from an incoming frame:

- `JsonRpcHandler.handle` maps each element of a batch array through `process_request`,
  which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]`
  or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`,
  so the error escaped; over stdio it escaped the `StdioTransport#open` read loop.
  The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio.
- `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`,
  which raises `NoMethodError` when the server emits a non-object frame.
  Only `JSON::ParserError` was rescued, so one malformed line from the server escaped.

Both are robustness gaps: a malformed frame should be reported or skipped, not raise
an unhandled exception out of the transport.

- `process_request` now returns an `INVALID_REQUEST` error response for a non-object request
  before touching `request[:id]`, so a malformed batch element becomes a normal per-element error.

- `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response,
  so an unexpected handling error still produces a JSON-RPC reply.
- `read_response` skips a non-object frame the same way it skips a frame without an id.

## How Has This Been Tested?

New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements
(`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element,
and `handle_json("[[]]")` returning an error instead of raising. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and
asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb`
has the server emit non-object frames before the response and asserts the client skips them
and still returns the response.

## Breaking Changes

None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
2026-07-11 17:06:37 +09:00
Koichi ITO
f013bcbf8c
Merge pull request #436 from koic/list_result_ttl
Add Opt-In `ttlMs` / `cacheScope` Cache Hints to List and Read Results per SEP-2549
2026-07-10 00:26:21 +09:00
Koichi ITO
04bb739c68 Support client-side elicitation with SEP-1034 schema defaults
## Motivation and Context

Resolves the `elicitation-sep1034-client-defaults` client conformance scenario
(SEP-1034, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034).

The Ruby HTTP client could not receive server-to-client requests at all:
it never opened the spec's standalone GET SSE listening stream, and SSE events that
were not responses were discarded. A server sending `elicitation/create` during a tools/call
(such as the conformance harness, which is built on the TypeScript SDK server)
either dropped the request silently or deadlocked the tool call waiting for an answer that
could never arrive.

This change adds three pieces:

### Server-to-client request dispatch

`MCP::Client::HTTP#on_server_request(method) { |params| ... }` registers a handler;
when a JSON-RPC request (a message with both `method` and `id`) arrives on any SSE stream,
the handler's return value is POSTed back to the server as the JSON-RPC `result`
(the server ACKs with 202 Accepted, per the transport spec).
Methods without a handler are answered with a `-32601` method-not-found error.
A handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code
(for example `-1` for a rejected sampling request, per the sampling spec),
mirroring the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return;
any other error is answered with a `-32603` internal error. In every case the server is not left waiting,
matching how the TypeScript and Python SDKs convert handler failures into JSON-RPC error responses.

### A standalone GET SSE listening stream

Registering a handler (once connected) opens the spec's listening stream on a background thread,
because servers deliver requests that are not tied to a client request on that stream -
the TypeScript SDK's `StreamableHTTPServerTransport` silently drops them when no GET stream is open.
Mirrors the TypeScript and Python SDK clients, which start listening right after the `initialize` handshake
without awaiting stream establishment. Reconnection semantics also follow both SDKs:
a gracefully closed stream is retried indefinitely after the server's `retry:` interval,
while consecutive connection failures (HTTP errors and idle read timeouts) stop the listener once
they reach the reconnection attempt cap, with a successful stream resetting the count;
a 405 stops immediately without retrying, since it is the spec's answer from a server that
offers no listening stream. The listening GET uses a 5-minute read timeout matching
the Python SDK's `sse_read_timeout` default. `close` terminates the thread.

### SEP-1034 defaults

`MCP::Client::Elicitation.apply_defaults` fills fields omitted from the elicitation content with
the `default` values declared in `requestedSchema` properties, never overwriting provided values
and skipping properties without a default. This follows the Python SDK, which applies defaults in
the elicitation callback; the TypeScript SDK's automatic `applyElicitationDefaults`
(gated on the `elicitation.form.applyDefaults` capability) is a possible follow-up.
`MCP::Client#on_elicitation` registers a handler for `elicitation/create` as a convenience.

The conformance client gains an `elicitation-sep1034-client-defaults` branch that connects with
`capabilities: { elicitation: {} }`, accepts the elicitation with defaults applied, and calls
the harness's `test_client_elicitation_defaults` tool; the scenario is removed from the expected failures baseline.

## How Has This Been Tested?

New unit tests covering: handler dispatch from a request's POST SSE stream with
the response body and 202 ACK verified, the `-32601` answer for unregistered methods,
the `-32603` answer for a raising handler, the custom-code answer when a handler raises
`ServerRequestError`, listener startup from both registration-after-connect and
connect-with-registered-handlers, listener shutdown after consecutive connection failures with
the count resetting after a successful stream and an immediate stop on 405,
and `apply_defaults` for all five SEP-1034 property types (string, integer, number, enum, boolean),
non-overwriting of provided values, omission of properties without defaults, and symbol-keyed schemas

## Breaking Changes

None. The listening stream and its background thread are created only when a server-request handler is registered;
clients that never call `on_server_request` / `on_elicitation` keep the existing single-request behavior.
2026-07-08 01:44:24 +09:00
Koichi ITO
ab65b919e2
Merge pull request #426 from koic/sse_retry
Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport
2026-07-08 01:09:56 +09:00
Koichi ITO
772e0cb1f9 Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS
## Motivation and Context

Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:

- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
  before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
  in the reporter's measurement. The path runs before session validation,
  so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
  so a peer that never emits a newline grows a single String until the process is OOM-killed.
  This matters when a host pipes an untrusted server's stdout into an unconstrained process.

The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.

- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
  rejects a declared `Content-Length` over the cap before reading, and also bounds
  the actual read to one byte past the cap so a missing or spoofed `Content-Length`
  (chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
  `max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
  The constructor validates the new option as a positive Integer, since `nil` or a non-positive
  value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
  a separate advisory, so this change carries the remaining piece of the reported stdio fix:
  an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
  so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
  fails cleanly instead of parsing a truncated frame.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).

## Breaking Changes

None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
2026-07-07 22:03:41 +09:00
Koichi ITO
267b8fa628 Bound stdio frame reads with max_line_bytes
## Motivation and Context

CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.

This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.

Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.

Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.

## How Has This Been Tested?

Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.

Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.

Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.

The full test suite and RuboCop pass.

## Breaking Changes

None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
2026-07-05 21:30:28 +09:00
Koichi ITO
36ff2627e0 Bind Stored Client Credentials to the Authorization Server Issuer per SEP-2352
## Motivation and Context

SEP-2352 (modelcontextprotocol/modelcontextprotocol#2352, merged for the 2026-07-28 spec release) clarifies
authorization server binding and migration: clients MUST key persisted client credentials by the issuing
authorization server's issuer identifier, MUST NOT reuse credentials across different authorization servers,
and MUST re-register when the server's authorization server changes. CIMD `client_id`s (HTTPS URLs) are
explicitly portable across authorization servers, and a mismatch SHOULD surface an error rather than silently use
mismatched credentials.

The Ruby flow previously reused any stored `client_information` with a `client_id` unconditionally,
so a server that migrated to a new authorization server would have the old server's credentials
(and its dead tokens) replayed forever. This change follows the detect-change, invalidate,
and re-register approach that both reference SDKs converged on (typescript-sdk#2348/#2358, merged 2026-06-24;
python-sdk#2933/#2936/#2946, which uses the same proactive compare-before-reuse design as this change)
while keeping the 4-method storage contract intact by stamping the binding into the persisted hash:

- Successful Dynamic Client Registration now persists the credentials with an `"issuer"` member set to
  the validated AS metadata `issuer` (which `ensure_issuer_matches!` has already pinned to the discovery URL).
- `Flow#ensure_client_registered` reuses stored credentials only when the stored issuer matches the current one.
  Credentials without an issuer binding (data persisted by older SDK versions, or user-supplied pre-registered credentials)
  are bound to the current issuer on first use. A portable CIMD `client_id` is reused and re-bound.
  Any other issuer mismatch discards the stale registration and its tokens (tokens minted by the old AS are dead at the new one)
  and re-registers via CIMD/DCR.
- `Flow#refresh!` raises `AuthorizationError` instead of replaying another authorization server's credentials at the token endpoint;
  `MCP::Client::HTTP#attempt_refresh` already rescues that error and falls back to the full flow, which performs
  the discard-and-re-register.
- The `client_credentials` grant (machine-to-machine) reads static pre-registered credentials without going through
  `ensure_client_registered`, so it would have silently sent another authorization server's credentials after a migration.
  `run_client_credentials!` now surfaces an `AuthorizationError` when the stored `issuer` mismatches the current authorization server,
  implementing the spec's "SHOULD surface an error" (the TypeScript SDK raises `AuthorizationServerMismatchError` here).
  Re-registration is not an option for pre-registered m2m credentials, so the operator must update the stored credentials;
  credentials without a recorded issuer keep working unchanged.

Two adjacent hardenings from the reference implementations were checked and need no change here:
the legacy-fallback issuer stamp is self-consistent because this SDK's fallback registers at the same origin it
uses as the issuer (the cross-origin never-reached-AS binding python-sdk#2946 closed cannot arise),
and preserving a non-rotated `refresh_token` across refreshes already exists via `preserve_refresh_token`.
The TypeScript SDK's redirected-to-issuer callback gate is not adopted: the RFC 9207 `iss` validation (SEP-2468) covers that mix-up leg.

Resolves #385.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb`:

- stored credentials with a matching issuer skip DCR
- an issuer mismatch re-registers and persists the new `client_id` with the new issuer
- the stale registration's tokens are cleared before re-registration (observed by aborting the flow at the DCR step)
- legacy credentials without an issuer (string- and symbol-keyed) are reused and bound to the current issuer on first use
- a CIMD `client_id` with a mismatched issuer is reused without DCR and re-bound
- fresh DCR persists the issuer binding
- `refresh!` succeeds with a matching stored issuer and raises (without contacting the token endpoint) with a mismatched one
- the `client_credentials` grant succeeds with a matching stored issuer and raises (without contacting the token endpoint) with
  a mismatched one

All existing flow, HTTP OAuth, and provider tests pass unchanged.

## Breaking Changes

None for the documented storage contract: `client_information` hashes gain an additional `"issuer"` member that storages persist opaquely,
and credentials stored without one keep working via first-use binding. Behavior changes only in the previously broken case where
the authorization server changed, which now re-registers (or, for static machine-to-machine credentials, surfaces an error) instead of
replaying the old server's credentials.
2026-07-05 01:12:08 +09:00
Koichi ITO
3339ceb8c1 Add Opt-In ttlMs / cacheScope Cache Hints to List and Read Results per SEP-2549
## Motivation and Context

SEP-2549 (modelcontextprotocol/modelcontextprotocol#2549, merged for the 2026-07-28 spec release) adds a `CacheableResult`
shape with `ttlMs` (freshness lifetime in milliseconds, max-age semantics, 0 = do not cache) and `cacheScope`
(`"public"` or `"private"`, whether shared intermediaries may cache) to `ListToolsResult`, `ListPromptsResult`,
`ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult`, complementing the `listChanged` notifications.

The field shape follows the Python SDK's implementation (python-sdk#2824, `ttlMs` + `cacheScope` with `ttlMs: 0` /
`cacheScope: "public"` defaults; the TypeScript SDK's earlier `ttl`-seconds prototype #2070 was superseded by this shape).
Because this SDK negotiates protocol versions up to 2025-11-25, where the fields do not exist, emission is opt-in:
when neither `ttl_ms` nor `cache_scope` is configured, responses are serialized exactly as before. The fields become REQUIRED
in 2026-07-28; a follow-up can auto-emit defaults once that version enters `SUPPORTED_STABLE_PROTOCOL_VERSIONS`.

- `MCP::Server.new` gains `ttl_ms:` and `cache_scope:` keywords with validating writers modeled on `page_size=`
  (`ttl_ms` must be nil or a non-negative Integer; `cache_scope` must be nil, `"public"`, or `"private"`).
- A private `apply_cache_metadata` wraps the five emission points. When the user sets only one of the two values,
  the other is filled with the spec defaults (`ttlMs: 0`, the only universally safe freshness value, and `cacheScope: "public"`,
  the spec and Python default).
- A `resources_read_handler` may return a full `{ contents:, ttlMs:, cacheScope: }` hash to override the server-level hints per result;
  the documented bare-contents return shape keeps working unchanged.
- The client's `ListToolsResult` / `ListPromptsResult` / `ListResourcesResult` / `ListResourceTemplatesResult` structs gain
  `ttl_ms` and `cache_scope` members populated from the response fields.

Resolves #387.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb`:

- with `ttl_ms: 5000`, all four list results carry `ttlMs: 5000` and the filled `cacheScope: "public"` default
- with only `cache_scope: "private"`, `resources/read` carries `ttlMs: 0`
- with neither configured, list and read results omit both fields (wire-format regression)
- the hints appear alongside `nextCursor` when paginating
- a `resources_read_handler` returning `{ contents:, ttlMs: 60_000 }` overrides the server-level value while the missing `cacheScope` is filled
- invalid writer values (`-1`, `1.5`, `"internal"`) raise `ArgumentError`

New tests in `test/mcp/client_test.rb` assert all four list result structs expose `ttl_ms` / `cache_scope`,
and that both are nil when the server omits the fields.

## Breaking Changes

None. Emission is opt-in, the new constructor keywords default to nil, and the client structs gain additive members.
2026-07-02 17:51:01 +09:00
Koichi ITO
4327fc8373 Support private_key_jwt Client Authentication for the client_credentials Grant
## Motivation and Context

The MCP `io.modelcontextprotocol/oauth-client-credentials` extension
(SEP-1046, modelcontextprotocol/modelcontextprotocol#1046) specifies
two client authentication methods for the `client_credentials` grant:
`client_secret_basic` and the RECOMMENDED `private_key_jwt`
(RFC 7523 Section 2.2 JWT client assertion). The previous commit added
the grant with secret-based authentication; this adds the JWT method,
which is what the `auth/client-credentials-jwt` conformance scenario exercises.

This mirrors the merged implementations in the TypeScript SDK
(`PrivateKeyJwtProvider`, typescript-sdk#1157) and the Python SDK
(`PrivateKeyJWTOAuthProvider` / `SignedJWTParameters`, python-sdk#1663),
adapted to this SDK's provider design:

- New `MCP::Client::OAuth::JWTClientAssertion` builds the RFC 7523 compact
  JWS with openssl, keeping the SDK free of a JWT gem dependency
  (the TypeScript and Python SDKs use jose and PyJWT). Claims follow SEP-1046:
  `iss` and `sub` carry the client_id, `aud` carries the authorization
  server's issuer identifier, plus `exp` (300-second lifetime, matching both SDKs),
  `iat`, and a unique `jti`. ES256 and RS256 are supported; ES256 signatures are
  converted from OpenSSL's ASN.1 DER form to the raw 64-byte `r || s` encoding
  JWS requires (RFC 7518 Section 3.4).
- `ClientCredentialsProvider` accepts
  `token_endpoint_auth_method: "private_key_jwt"` with `private_key:`
  (PEM string or `OpenSSL::PKey::PKey`) and a required `signing_algorithm:`
  (no default, so an algorithm mismatch with the server's
  `token_endpoint_auth_signing_alg_values_supported` fails loudly at construction;
  both reference SDKs also take the algorithm explicitly).
  The key stays on the provider and is never written to `storage`;
  a throwaway assertion is signed at construction to fail fast on an unparseable key
  or a key/algorithm mismatch.
- `Flow#post_to_token_endpoint` sends `client_assertion_type` and a freshly
  signed `client_assertion` for `private_key_jwt`,
  using the `ensure_issuer_matches!`-validated issuer as the audience,
  and omits `client_id` from the body per SEP-1046
  (identity travels in the assertion's claims).
- The conformance client builds a `private_key_jwt` provider
  when the harness injects `private_key_pem`, defaulting the algorithm to
  ES256 like the TypeScript and Python conformance clients,
  and `auth/client-credentials-jwt` is removed from
  `conformance/expected_failures.yml`.

Scope notes: only ES256 and RS256 are supported for now (the TypeScript and
Python SDKs accept whatever their JWT libraries support);
adding further algorithms is additive. There is also no injection point yet for
externally signed assertions (the TypeScript SDK's `StaticPrivateKeyJwtProvider` and
the Python SDK's `assertion_provider` callback cover KMS/HSM-held keys);
that is a separate follow-up.

## How Has This Been Tested?

New `test/mcp/client/oauth/jwt_client_assertion_test.rb` covers the JWS construction:
header and RFC 7523 claims, unpadded base64url segments,
ES256 signature round-trip verification (raw -> DER -> `OpenSSL::PKey::EC#verify`),
RS256 verification, PEM and parsed-key input, lifetime override, unique `jti` per assertion,
and rejection of unsupported algorithms, key/algorithm mismatches
(including a P-384 key with ES256), unparseable PEM, and public-only keys.

New tests in `test/mcp/client/oauth/client_credentials_provider_test.rb` cover
the provider surface: `private_key_jwt` construction, the key and
secret never reaching `client_information`, required `private_key` / `signing_algorithm`,
rejection of a simultaneous `client_secret`, fail-fast on key/algorithm mismatch,
and `client_assertion(audience:)` producing a JWT with the expected claims.

A new test in `test/mcp/client/oauth/flow_test.rb` runs the full grant against WebMock stubs
and asserts the token request carries `grant_type=client_credentials`,
the RFC 7523 `client_assertion_type`, an assertion whose `iss`/`sub` are the client_id
and whose `aud` is the issuer, the RFC 8707 `resource`, and neither `client_id` nor
any secret in the body or headers.

`bundle exec rake` passes; the `auth/client-credentials-jwt` conformance scenario
passes 7/7 checks and the baseline check passes with its expected-failure entry removed.

## Breaking Changes

None. `client_secret:` becomes optional on `ClientCredentialsProvider` only
in the signature; it is still required for the secret-based methods, and all
existing construction paths behave unchanged.
2026-06-28 20:59:37 +09:00
Koichi ITO
14b4f7c8c7 Validate the RFC 9207 iss Authorization Response Parameter per SEP-2468
## Motivation and Context

SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468, merged for the 2026-07-28 spec release) recommends
that authorization servers include the RFC 9207 `iss` parameter in authorization responses and requires MCP clients
to validate a present `iss` against the expected issuer before redeeming the authorization code.
This mitigates mix-up attacks in multi-IdP setups, where an attacker-controlled authorization server could otherwise capture
a code minted by an honest server.

This mirrors the TypeScript SDK's approach (typescript-sdk#2272, with typescript-sdk#1957 as the reference implementation),
adapted to the Ruby provider contract:

- `Provider`'s `callback_handler` may now return `[code, state, iss]` in addition to the existing `[code, state]`.
  The 3-element form opts into validation; returning `iss = nil` asserts the caller inspected the authorization response
  and found no `iss`.
- `Flow#run!` validates the issuer after the `state` check and before `exchange_authorization_code`,
  implementing the RFC 9207 Section 2.4 rules: a present `iss` must equal the AS metadata `issuer` exactly
  (the value `ensure_issuer_matches!` already pinned to the discovery URL); a nil `iss` from a 3-element callback is rejected
  when the AS metadata advertises `authorization_response_iss_parameter_supported: true`; a legacy 2-element callback skips the check,
  so every existing integration keeps working. On any violation the flow aborts with `AuthorizationError` before
  the code reaches a token endpoint.
- The conformance client now returns the 3-element form, plumbing `iss` through from the redirect URL.

Resolves #386.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb`:

- a matching `iss` completes the flow and persists tokens
- a mismatched `iss` raises `AuthorizationError` and never contacts the token endpoint (`assert_not_requested` on the token POST)
- a nil `iss` with `authorization_response_iss_parameter_supported: true` raises without contacting the token endpoint (fail closed)
- a legacy 2-element callback completes even when the AS advertises `iss` support (backward compatibility)
- a nil `iss` without the advertisement completes
- a mismatched `iss` is rejected even without the advertisement (validate-when-present is unconditional)

`bundle exec rake` (tests, RuboCop, and conformance baseline with the 3-element conformance callback) passes.

## Breaking Changes

None. The 2-element `callback_handler` contract continues to work unchanged; issuer validation activates only
when a callback opts in by returning the 3-element form.
2026-06-28 20:40:29 +09:00
Koichi ITO
cb0eb3892b Support SSE Reconnection Per SEP-1699 in the HTTP Client Transport
## Motivation and Context

Resolves the `sse-retry` client conformance scenario (SEP-1699,
https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699).

Per SEP-1699, a server may close a request's SSE stream right after a priming event
(an event carrying an `id:`) without delivering the response, expecting the client
to treat the graceful close like a network failure: wait the server-specified
`retry:` interval, then reconnect with an HTTP GET carrying `Last-Event-ID`
so the server can replay the pending response on the resumed stream.

The Ruby client previously read the entire SSE body to EOF and parsed it afterward,
so it had no notion of per-event state (`id:`, `retry:`) or reconnection,
and the scenario failed all three checks (graceful reconnect, retry timing, `Last-Event-ID`).

`MCP::Client::HTTP#send_request` now consumes responses incrementally
via Faraday's `on_data` streaming callback, feeding SSE chunks to `event_stream_parser`
as they arrive while buffering plain JSON bodies as before. A new internal `SSEStream`
tracks the last received event id, the `retry:` reconnection delay,
and the awaited JSON-RPC response; once the response arrives, a held-open stream is
abandoned via an internal control-flow exception since servers may never close it.
When a stream closes gracefully after a priming event but before the response,
the client sleeps for the `retry:` interval (default 1000ms when the server sent none),
then issues a GET with `Accept: text/event-stream`, the session headers, and `Last-Event-ID`,
for up to 2 attempts. The defaults match the Python SDK's `DEFAULT_RECONNECTION_DELAY_MS` and
`MAX_RECONNECTION_ATTEMPTS` (`streamable_http.py`); the TypeScript SDK's `StreamableHTTPClientTransport`
(`streamableHttp.ts`) likewise lets the `retry:` field override its backoff and reconnects only when
a priming event was received and no response has arrived.

The public behavior of `send_request` is unchanged: same return values, same error mapping,
and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error
without reconnecting. Because the previous implementation read `response.body` and therefore worked with any Faraday adapter,
a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read from `response.body`
(adapters without `on_data` support, e.g. the Faraday test adapter) or from the buffered chunks
(Faraday < 2.1, which invokes `on_data` without `env`).

The conformance client gains an `sse-retry` branch that calls the harness's `test_reconnection` tool,
and the scenario is removed from the expected failures baseline.

## How Has This Been Tested?

New unit tests covering: reconnection with `Last-Event-ID` after a primed graceful close including the `retry:` wait,
the default 1000ms delay when `retry:` is absent, raising after the reconnection attempt cap with `Last-Event-ID`
advancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks
(JSON and SSE responses through the Faraday test adapter, which ignores `on_data`, plus buffered SSE chunks
when `on_data` receives no `env`)

## Breaking Changes

None. The `send_request` contract is unchanged; SSE bodies are now parsed incrementally instead of after EOF,
and reconnection only activates when the server opts in by sending a priming event.
2026-06-28 11:56:02 +09:00
Koichi ITO
d6a368a54f Require calling MCP::Client#connect before sending requests on stdio transport
## Motivation and Context

The MCP specification requires an `initialize` request followed by `notifications/initialized`
before any other interaction. Issue #334 makes that handshake explicit on the stdio client,
matching the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`).

The change was staged to avoid a hard break. Phase 1 (#336) added `MCP::Client::Stdio#connect`
while keeping an implicit-init compatibility shim on the first `send_request`.
Phase 2 (#338) emitted a deprecation warning when that shim ran. The warning has shipped since 0.16.0,
so this final phase removes the shim.

## How Has This Been Tested?

`send_request` now raises a `RuntimeError` when `connect` has not been called, and no longer
auto-starts the subprocess. `connect` is the sole entry point that spawns the process and
performs the handshake, which avoids leaving an orphaned subprocess behind.

The stdio test suite was reworked to call `connect` explicitly, a regression test was added for
the new guard, and `rake test` passes (1145 runs, 0 failures).

## Breaking Changes

Stdio clients that send requests without calling `MCP::Client#connect` now raise a `RuntimeError`
instead of silently initializing. Call `client.connect` before the first request.

Closes #334
2026-06-26 13:34:47 +09:00
Koichi ITO
a284bf7314 Send SEP-2243 Mcp-Method and Mcp-Name headers per MCP specification
## Motivation and Context

The MCP specification (SEP-2243) requires the Streamable HTTP transport to mirror
selected JSON-RPC body fields into HTTP headers so that intermediaries such as load balancers,
gateways, and observability tooling can route and inspect requests without parsing the body.
`Mcp-Method` carries the JSON-RPC `method` and is required on all requests and notifications,
while `Mcp-Name` carries `params.name` (`tools/call`, `prompts/get`) or `params.uri` (`resources/read`).

The Ruby SDK client did not send these headers, so servers enforcing SEP-2243 rejected
its requests with `400 Bad Request`, breaking interoperability. This aligns the Ruby SDK with
the TypeScript and Python SDKs, both of which send these headers from their Streamable HTTP clients.

Header values that cannot be transmitted safely as-is, such as non-ASCII text, control characters
(including CR/LF, which would otherwise allow header injection), or significant leading or
trailing whitespace, are wrapped per SEP-2243 as `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
The encoding uses `Array#pack("m0")` to avoid depending on the `base64` gem.

Resolves #366

## How Has This Been Tested?

Client tests cover deriving `Mcp-Name` from `params.name` (`tools/call`, `prompts/get`) and
falling back to `params.uri` (`resources/read`), both symbol- and string-keyed params,
requests and notifications without a name or uri sending only `Mcp-Method`, and non-Hash params sending
only `Mcp-Method`. Value encoding is covered for non-ASCII names, surrounding whitespace, CR/LF
(verifying no header injection), and values that already match the `=?base64?...?=` sentinel.

## Breaking Changes

None. The change is purely additive: existing requests now carry the required `Mcp-Method` and `Mcp-Name` headers
alongside the headers already sent. The `x-mcp-header` / `Mcp-Param-*` custom headers remain out of scope,
matching the current state of the TypeScript and Python SDKs.
2026-06-24 14:04:36 +09:00
Koichi ITO
a4cd7c8cde Support client-side notifications/cancelled per MCP specification
## Motivation and Context

The server-side half of MCP `notifications/cancelled` shipped earlier and lets handlers
observe cancellation via `server_context.cancelled?` / `raise_if_cancelled!`.
This PR delivers the client-side equivalent that was deferred at the time:
an `MCP::Client` user can now cancel a request they have already issued and have
the calling thread wake up, matching the Python SDK
(`anyio.CancelScope`) and TypeScript SDK (`AbortSignal`) ergonomics.

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
cancellation = MCP::Cancellation.new
Thread.new do
  client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
rescue MCP::CancelledError
  # cleanup
end
cancellation.cancel(reason: "user pressed cancel")
```

For low-level use, `Client#cancel(request_id:, reason:)` sends the notification without managing
the calling thread, and `Client#generate_request_id` lets callers pre-allocate an id before kicking off
a request on another thread. The `request_id:` and `cancellation:` keywords are accepted by
every request method (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`,
`list_resource_templates`, `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`,
`complete`, `ping`).

When `cancellation:` is supplied, the actual blocking `transport.send_request` runs on a worker thread;
the calling thread waits on a `Queue` woken either by the response or by the cancel signal
(whichever arrives first - the same race contract as the server-side `StreamableHTTPTransport#cancel_pending_request`).
On a normal completion the `on_cancel` hook is deregistered so a late cancel does not emit a stray `notifications/cancelled`.
The worker thread is *not* force-killed when a cancel wins the race; it stays blocked on the underlying I/O until
the transport actually returns (or the user closes it). For `Client::HTTP` the leak resolves as soon as
the server sends any response; for `Client::Stdio` the user may need to call `client.transport.close`
if the server stops responding entirely.

`Client::Stdio` and `Client::HTTP` gain `send_notification(notification:)` so `Client#cancel` can deliver
the JSON-RPC notification through the existing transport plumbing. Custom transports that do not implement
`send_notification` cause `Client#cancel` to raise `NoMethodError`.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation

## How Has This Been Tested?

`test/mcp/client_test.rb` covers `Client#cancel` payload (with and without `reason`),
`Client#generate_request_id` returning distinct UUIDs, every request method threading `request_id:` through,
and the full `cancellation:` flow:

- Returns the response when no cancel happens (no behaviour regression for callers that do not pass a token).
- Pre-cancelled token raises `MCP::CancelledError` immediately and never reaches the transport
  (no stray request, no stray notification).
- Mid-flight cancel raises `MCP::CancelledError` carrying the supplied reason and sends `notifications/cancelled`
  through the transport.
- Late cancel after a normal completion does not emit a stray notification
  (the `on_cancel` hook is deregistered in the `ensure` block).

`test/mcp/client/stdio_test.rb` covers `send_notification` writing the JSON line through the spawned process
and returning `nil` without waiting for a response.

`test/mcp/client/http_test.rb` covers `send_notification` POSTing the body, tolerating HTTP 202 Accepted,
and surfacing Faraday errors as `RequestHandlerError`.

## Breaking Change

None. All new keywords (`request_id:`, `cancellation:`) on request methods are optional and default to `nil`.
Existing callers that do not pass them get the original synchronous behaviour. Custom transports that already
implement `send_request(request:)` continue to work; only callers of `Client#cancel` need a transport
that responds to `send_notification`, and the failure mode is a clear `NoMethodError` rather than silent breakage.
2026-06-23 12:39:53 +09:00
Koichi ITO
aac41cdff8
Merge pull request #408 from koic/dcr_application_type
Set OIDC `application_type` on Dynamic Client Registration per SEP-837
2026-06-17 10:57:19 +09:00
Koichi ITO
2c223dd2f0 Fall Back to Legacy 2025-03-26 OAuth Discovery for Servers Without PRM
## Motivation and Context

The MCP 2025-03-26 authorization spec predates Protected Resource Metadata:
the MCP server itself acted as the authorization base URL, its RFC 8414 metadata was fetched from
the server origin, and the spec's "Fallbacks for Servers without Metadata Discovery" section
required clients to use the default endpoints `/authorize`, `/token`, and `/register` relative to
the authorization base URL when no metadata was published. Both the TypeScript SDK
(`discoverOAuthServerInfo` falls back to the server base URL on PRM failure; `startAuthorization` /
`executeTokenRequest` / `registerClient` default the endpoint paths) and the Python SDK
(`build_oauth_authorization_server_metadata_discovery_urls(None, ...)` plus the same endpoint defaults)
keep this as client-side backwards compatibility, and the `auth/2025-03-26-oauth-metadata-backcompat` and
`auth/2025-03-26-oauth-endpoint-fallback` conformance scenarios exercise it.

The Ruby flow previously raised as soon as PRM discovery failed, so both scenarios were
expected conformance failures. This change adds the same fallback:

- `Flow#locate_authorization_server` first attempts PRM discovery; on any discovery failure
  (404s, network errors, malformed documents, matching the TypeScript and Python SDKs' broad fallback)
  the MCP server's origin becomes the legacy authorization base URL. The Communication Security check
  (HTTPS or loopback) still applies to that origin, and PRM documents that parse correctly keep
  their strict shape validation.
- `Flow#authorization_server_metadata` fetches RFC 8414 metadata from the base URL.
  On the legacy path the `issuer` byte-match is skipped: the 2025-03-26 spec predates that requirement,
  and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs
  from the discovery origin (neither reference SDK validates the issuer on this path). On the modern path
  the byte-match is unchanged.
- When even the metadata document is absent, synthetic metadata carrying the legacy spec's default endpoints
  is used, with PKCE S256 assumed (the legacy spec mandates PKCE; the TypeScript and Python SDKs hardcode S256
  on this path too). Endpoint HTTPS checks still apply.
- `run!` and `refresh!` share the new discovery helpers, and both 2025-03-26 scenarios are removed from
  `conformance/expected_failures.yml`.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb`:

- the metadata-backcompat shape: no PRM, AS metadata at the server origin with `/oauth`-prefixed endpoints
  and a mismatched `issuer` completes the flow against those endpoints (no issuer error)
- the endpoint-fallback shape: no metadata at all registers, authorizes, and exchanges the code at `/register`,
  `/authorize`, and `/token` on the origin, still sending `code_challenge` (S256) and `code_verifier`
- a remote plain-http origin is rejected as the legacy authorization base
- strict-mode regression: with PRM present, a mismatched `issuer` still aborts the flow
- a malformed PRM (top-level JSON array) now selects the legacy path instead of leaking a `TypeError`,
  and surfaces a domain error when that path also dead-ends

Two existing tests that asserted PRM failure was terminal were updated to dead-end the legacy path explicitly,
preserving their original intent (single flow attempt, domain error type).

## Breaking Changes

None for spec-compliant 2025-06-18+ servers, whose PRM-based discovery and issuer validation are unchanged.
Servers that previously failed hard during PRM discovery now get one legacy discovery attempt before the flow errors,
which can only turn previously failing flows into working ones.
2026-06-16 17:48:18 +09:00
Koichi ITO
3093b3ee3f Set OIDC application_type on Dynamic Client Registration per SEP-837
## Motivation and Context

SEP-837 (modelcontextprotocol/modelcontextprotocol#837, merged for the 2026-07-28 spec release)
updates the authorization spec to require MCP clients to specify an appropriate OIDC `application_type`
(`"native"` vs `"web"`) during Dynamic Client Registration, so the authorization server can apply
the matching redirect URI policy and native and web clients do not collide on redirect URI validation rules.

This mirrors the approach taken by the TypeScript SDK (typescript-sdk#2266) and the Python SDK (python-sdk#2784):

- `MCP::Client::OAuth::Discovery.infer_application_type(redirect_uris)` returns `"native"` when every redirect URI
  is a native-app URI (a custom  non-http(s) scheme per RFC 8252 Section 7.1, or an http(s) URI with a loopback host
  per RFC 8252 Section 7.3) and `"web"` otherwise, including for nil, empty, or unparseable input.
  Loopback detection reuses the existing `loopback_host?` helper, so lookalike hosts such as `localhost.example.com`
  or `127.attacker.com` are not treated as native.
- `Flow#ensure_client_registered` now posts the metadata through the new `registration_client_metadata` helper,
  which merges the inferred `application_type` into the DCR request body only when the user did not set
  one explicitly (symbol or string key); an explicit value always wins.

Resolves #375.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/discovery_test.rb` cover the inference: loopback http URIs (IPv4, IPv6, `localhost`),
custom schemes, https URIs, mixed lists, the `localhost.example.com` lookalike, nil/empty input, and unparseable URIs.

New tests in `test/mcp/client/oauth/flow_test.rb` assert on the actual DCR request body via WebMock `assert_requested`:
loopback redirect URI registers `"application_type": "native"`, an HTTPS redirect URI registers `"web"`,
and an explicit `application_type` (symbol- or string-keyed) is never overridden.

## Breaking Changes

None. The DCR request body gains an `application_type` member only when the user has not specified one;
RFC 7591 requires authorization servers to accept and use known metadata fields, and OIDC-registration servers
already default this field to `"web"` when absent.
2026-06-15 00:09:39 +09:00
Koichi ITO
8e41474f71
Merge pull request #395 from koic/rfc8414_well_known_suffix
Pin RFC 8414 Default Well-Known Suffix per SEP-2351
2026-06-13 20:23:10 +09:00
Koichi ITO
cf27c5dca6
Merge pull request #399 from koic/client_credentials_basic
Support OAuth `client_credentials` grant in OAuth client
2026-06-13 20:06:29 +09:00
Koichi ITO
2289db84f8 Support OAuth client_credentials grant in OAuth client
## Motivation and Context

The MCP authorization specification and SEP-1046 allow confidential clients to
obtain tokens through the OAuth 2.1 `client_credentials` grant: a machine-to-machine flow
with no user, no browser redirect, and no authorization code. The Ruby SDK
previously supported only the interactive authorization-code flow, so it could not
authenticate against a server that requires `client_credentials`. This makes
the `auth/client-credentials-basic` conformance scenario pass and is removed from
the expected failures list.

Following the TypeScript SDK (`ClientCredentialsProvider`) and the Python SDK
(`ClientCredentialsOAuthProvider`), the grant gets its own provider class rather than
overloading the authorization-code provider. The change adds:

- `MCP::Client::OAuth::ClientCredentialsProvider`, a dedicated provider
  for the grant. `client_id` and `client_secret` are mandatory
  (the grant is for confidential clients), `token_endpoint_auth_method` is
  `client_secret_basic` (default) or `client_secret_post` -- `none` is
  rejected because an unauthenticated `client_credentials` request is
  meaningless. It has no redirect arguments, so a credentials-only
  client never supplies the redirect URI / handlers that grant has no use for.
- `MCP::Client::OAuth::StorageBackedProvider`, an internal module that
  shares the token / client-information persistence between the two provider classes
  so `Flow` can treat any provider uniformly.
- `Provider#authorization_flow` (`:authorization_code`) and
  `ClientCredentialsProvider#authorization_flow` (`:client_credentials`).
  `Flow#run!` dispatches on this rather than inspecting
  `client_metadata[:grant_types]`, which is protocol metadata for
  the authorization server, not an SDK control signal.
- `run_client_credentials!` shares the same PRM / AS metadata discovery
  and Communication Security checks as the interactive flow, then
  exchanges `grant_type=client_credentials` at the token endpoint via
  the existing `post_to_token_endpoint` (which already applies
  `client_secret_basic`). There is no PKCE, redirect, or `offline_access`
  augmentation, because the grant does not issue a refresh token
  (OAuth 2.1 Section 4.3.3).
- `conformance/client.rb` builds a `ClientCredentialsProvider` for
  the `auth/client-credentials-*` scenarios, reusing the harness-injected
  `client_id` / `client_secret`.

## How Has This Been Tested?

New `ClientCredentialsProvider` tests cover: credentials are stored as
client information; `authorization_flow` is `:client_credentials`;
`client_secret_post` is accepted; missing `client_id` / `client_secret`
and `token_endpoint_auth_method: "none"` are rejected; and the token helpers
delegate to storage.

New `Flow` tests cover: a `client_credentials` provider exchanges
`grant_type=client_credentials` with HTTP Basic auth, saves the token,
and never contacts the authorization or registration endpoint;
the requested scope is taken from PRM `scopes_supported`; and a standard
`Provider` runs the authorization-code grant regardless of what its
`client_metadata[:grant_types]` lists. A `Provider` test asserts
`authorization_flow` is `:authorization_code`.

Conformance: `auth/client-credentials-basic` now passes 7/7 with no failures.

## Breaking Changes

None. `ClientCredentialsProvider` is a new class; the existing `Provider` keeps
its required `redirect_uri` / `redirect_handler` / `callback_handler` arguments
and its behaviour is unchanged. Existing interactive clients are unaffected.
2026-06-13 00:12:19 +09:00
Koichi ITO
a407cea086 Re-run OAuth flow on 403 insufficient_scope (step-up)
## Motivation and Context

The MCP authorization specification (2025-11-25 scope-selection-strategy)
and RFC 6750 Section 3.1 describe OAuth 2.0 step-up authentication:
the client already holds a valid access token, but the server returns a 403
with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
to demand a higher-privileged scope for a specific operation.
The client is expected to run a fresh authorization request with
the escalated scope and retry.

Until now the Ruby SDK only ran the OAuth flow on 401 responses.
A 403 surfaced unchanged as a `RequestHandlerError` and the operation
that needed the escalated scope failed permanently, even with a valid token
in storage. This lined up with the `auth/scope-step-up` conformance
scenario being listed in `conformance/expected_failures.yml`.

The change adds:

- `MCP::Client::HTTP#send_request` recognises a 403 carrying
  an `insufficient_scope` Bearer challenge as a step-up signal,
  re-runs a full authorization request with the scope from the challenge,
  and retries the original request. Retries are capped at
  one per `send_request` call so a server that repeatedly demands more
  scope cannot loop the transport.
- `run_step_up_flow!` deliberately bypasses the refresh-token path that
  `run_oauth_flow!` uses on 401. Refreshing would re-issue the same
  scope set the existing token already has and the server already
  rejected. Only a fresh `authorization_code` grant with the escalated
  scope can succeed.
- A plain 403 with no `insufficient_scope` challenge continues to
  surface as `RequestHandlerError` with `error_type: :forbidden`.
- `conformance/client.rb` invokes a tool for the `auth/scope-step-up`
  scenario; the scenario's authorization middleware only requires
  the escalated scope on `tools/call`, so the previous `tools/list`-only
  client could never trigger the second authorization.
- `auth/scope-step-up` is removed from `conformance/expected_failures.yml`.

Resolves #383.

## How Has This Been Tested?

New `HTTPOAuthTest` cases cover: the 403 insufficient_scope path
re-authorizes with the escalated scope from the challenge and retries
the original request with the new bearer token; a plain 403 without
the `insufficient_scope` challenge is surfaced as
`error_type: :forbidden` with no OAuth side effects; repeated
`insufficient_scope` responses retry at most once and then surface the 403;
a provider with a saved `refresh_token` still goes through
the full authorization request rather than exchanging the refresh token,
because the existing scope set is precisely what was rejected.

Conformance: `auth/scope-step-up` now passes 22/22 with no warnings.
`bundle exec rake test`, `bundle exec rake rubocop`, and
`bundle exec rake conformance` are all green.

## Breaking Changes

None. Behaviour on 401 is unchanged. A 403 without the `insufficient_scope`
Bearer challenge still raises the same `RequestHandlerError` as before.
The new retry only fires when both the WWW-Authenticate header signals step-up
and the transport was constructed with an OAuth provider.
2026-06-12 11:59:02 +09:00
Koichi ITO
2a26ad2030 Pin RFC 8414 Default Well-Known Suffix per SEP-2351
## Motivation and Context

SEP-2351 (modelcontextprotocol/modelcontextprotocol#2351, merged for the 2026-07-28 spec release)
makes explicit that MCP uses the default `oauth-authorization-server` well-known URI suffix from
RFC 8414 Section 3.1 and defines no application-specific suffix.

`MCP::Client::OAuth::Discovery.authorization_server_metadata_urls` already conforms:
it probes the RFC 8414 default suffix first (path-inserted form for path issuers) and
otherwise uses only the OpenID Connect Discovery `openid-configuration` suffix.
Neither the Python SDK nor the TypeScript SDK needed a behavior change for this SEP either;
their tracking issues (python-sdk#2799, typescript-sdk#2256) remain documentation-level.

This change documents the SEP-2351 guarantee in the discovery helper and locks the behavior
in with regression tests so a future refactor cannot silently introduce a non-default suffix.

Resolves #384.

## How Has This Been Tested?

New regression tests in `test/mcp/client/oauth/discovery_test.rb`:

- the first probed candidate for a root issuer is exactly `/.well-known/oauth-authorization-server`
- every generated candidate uses only the `oauth-authorization-server` or `openid-configuration` suffix
- the RFC 8414 path-inserted `oauth-authorization-server` candidate comes first for issuers with a path component
- an issuer URL with a trailing slash is treated as a root issuer

## Breaking Changes

None. Documentation and regression tests only; no behavior change.
2026-06-10 17:48:28 +09:00
Koichi ITO
cf44475ce9
Merge pull request #365 from koic/conformance_client_auth_offline_access_scope
Request offline_access scope when supported (SEP-2207)
2026-06-01 01:09:01 +09:00
Koichi ITO
60416c9ac0
Merge pull request #361 from koic/conformance_client_auth__basic_cimd
Support Client ID Metadata Documents in OAuth client
2026-05-31 03:13:16 +09:00
Koichi ITO
fdca6f5a2f Request offline_access scope when supported (SEP-2207)
## Motivation and Context

SEP-2207 describes how an OAuth client obtains refresh tokens: it
declares the `refresh_token` grant type in its client metadata and
requests the `offline_access` scope, but only when the authorization
server advertises `offline_access` in its metadata `scopes_supported`.
Requesting `offline_access` when the server does not list it is forbidden.

The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never
requested `offline_access`, so it could not obtain a refresh token from a server
that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs
and makes the `auth/offline-access-scope` conformance scenario pass
(previously it reported one warning, `sep-2207-client-metadata-grant-types`,
and was listed in the expected-failures baseline).

The change adds:

- `Flow#augment_scope_with_offline_access`, called from `run!` after
  `resolve_scope`, appends `offline_access` to the requested scope when
  both the client opted into refresh tokens (its registered
  `grant_types` include `refresh_token`) and the authorization server
  advertises `offline_access` in its metadata `scopes_supported`.
  Already-present `offline_access` is not duplicated. Gating on the server
  advertisement keeps the SDK from ever requesting the scope where it is not supported.
- `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection
  of the SDK's refresh support, which clears the SEP-2207 grant-types warning.
- `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`.

## How Has This Been Tested?

New `Flow` tests cover: `offline_access` is requested when the server advertises it
and the client declared the `refresh_token` grant; `offline_access` is NOT requested
when the client did not declare the grant; `offline_access` is NOT requested
when the server does not advertise it (even with the grant declared);
and `offline_access` is not duplicated when it is already part of the resolved scope.

Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings,
and `auth/offline-access-not-supported` still passes 13/13 (no regression).
`bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance`
are all green.

## Breaking Changes

None. `offline_access` is appended only when the client's own `grant_types` opt
into refresh tokens and the authorization server advertises the scope, so existing clients
that do neither see no change in the scope they request.
2026-05-29 13:14:14 +09:00
Koichi ITO
355c904899 Support Client ID Metadata Documents in OAuth client
## Motivation and Context

The MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00` define
Client ID Metadata Documents (CIMD): a client publishes its OAuth metadata at an HTTPS URL and uses that URL
as the OAuth `client_id` instead of going through Dynamic Client Registration. The Python and TypeScript SDKs
already implement this; the Ruby SDK now reaches parity.

The change adds:

- `MCP::Client::OAuth::Provider` accepts an optional `client_id_metadata_document_url:` keyword.
- A new `Discovery.client_id_metadata_document_url?` validator enforces structural requirements.
  Spec-required: `https` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments
  (including the percent-encoded form `%2E`). Stricter than the draft as an SDK policy: query strings
  are also rejected, because different encodings of the same query would yield distinct `client_id` strings for
  the same logical document. The loopback `http` carve-out used for discovery URLs does not extend to CIMD URLs,
  since the value is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback.
  Validation failures raise `Provider::InvalidClientIDMetadataDocumentURLError`.
- `Flow#ensure_client_registered` routes through CIMD only when the authorization server advertises
  `client_id_metadata_document_supported` as JSON `boolean true`; truthy non-boolean values
  (string `"true"`, `"false"`, etc.) fall back to DCR, matching the gating in the Python and TypeScript SDKs.
- The CIMD `client_id` is NOT persisted to storage. AS support is re-read on every flow so a server
  that later drops CIMD support stops receiving the stale URL. Pre-registered `client_information`
  continues to win over CIMD when both are available.
- `Flow#refresh!` reconstructs the CIMD `client_id` from live AS metadata when refreshing tokens obtained via CIMD.
  When support has been withdrawn and no other identity is stored, it raises a clear `AuthorizationError` rather than
  sending a `client_id` the authorization server no longer recognises.
- The Provider docstring and README OAuth section explain that DCR `client_metadata` and the JSON document served at
  the CIMD URL are separate artifacts: DCR metadata MUST NOT include `client_id`, while the CIMD document MUST include
  `client_id` set to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `auth/basic-cimd` is removed from `conformance/expected_failures.yml`.

## How Has This Been Tested?

`Discovery` tests cover: https with path accepted; non-https rejected (http, ftp, file); root and empty path rejected;
fragment, query, and userinfo rejected; dot segments rejected including the percent-encoded form `%2E`; nil, empty,
and malformed inputs rejected.

`Provider` tests cover: the default `nil`; rejection of http URLs (loopback included); non-https schemes;
root and empty path; fragment; query; userinfo; and dot segments.

`Flow#run!` tests cover: skip DCR when the authorization server advertises CIMD and the provider has a URL;
fall back to DCR when the authorization server does not advertise support; fall back to DCR when the value is
the string `"false"` or `"true"`; pre-registered `client_information` wins over CIMD; the CIMD `client_id` is not
persisted to storage; a second flow against an authorization server that has dropped CIMD support recovers via DCR.

`Flow#refresh!` tests cover: refresh reconstructs the CIMD `client_id` without a stored `client_information`;
refresh raises when the authorization server no longer advertises CIMD support; stored `client_information`
takes precedence over the CIMD URL during refresh.

Conformance: `auth/basic-cimd` now passes 12/12 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`,
and `bundle exec rake conformance` are all green.

## Breaking Changes

None. The `client_id_metadata_document_url:` keyword is purely opt-in and defaults to `nil`.
When the keyword is omitted, the DCR flow is unchanged. Existing OAuth users see no behaviour change.
The CIMD path is only entered when both the user explicitly configures a URL and the authorization server
advertises support via `client_id_metadata_document_supported: true`.
2026-05-26 18:56:03 +09:00