Commit graph ruby-sdk/README.md
Author SHA1 Message Date
Koichi ITO
b159314baa Fulfill input_required results on the legacy wire per SEP-2322
## Motivation and Context

SEP-2322 handlers return `InputRequiredResult` instead of issuing in-flight server-to-client requests,
but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown `resultType`
as a final result, so the server rejected `input_required` on legacy requests with an internal error.
That forced authors to keep two handler styles, one per era.

This adds the dual-era authoring shim both reference implementations converged on:
the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381)
and the Python SDK's version-negotiating resolver (python-sdk#2986).
When a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on
the legacy wire, the server fulfills it in place of the client's driver:

- Every `inputRequests` entry is sent verbatim as the equivalent real server-to-client request through
  a new `ServerSession#fulfill_input_request` (per the spec, clients treat each embedded entry exactly like
  the standalone request), stamped with the originating request id so the association requirement of
  SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream. One exception to
  the verbatim forwarding: a URL-mode elicitation leg gains a synthesized `elicitationId` when
  the handler supplied none, since the 2025-11-25 wire requires the field and the 2026-07-28 in-band
  shape dropped it (correlation rides `requestState` there) - matching the TypeScript shim.
- The answers are collected under the same keys and the handler re-runs with `inputResponses`
  and the raw `requestState` merged into the original params - the same deterministic replay contract
  the modern client driver follows, read through the existing `server_context.input_responses`/`request_state` accessors.
  Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through `requestState`.
- `requestState` round-trips in-process as the value the handler wrote. `RequestStateSecurity` sealing is
  wire hardening against a tampering client and does not apply to the in-process replay.
- The embedded-capability gate still runs each round against the session's declared client capabilities;
  a violation fails before any client contact. The `-32021` code is 2026-only, so the legacy wire gets
  an internal error naming the missing capabilities.
- Rounds are capped at 8 (`LEGACY_INPUT_REQUIRED_MAX_ROUNDS`), matching the TypeScript shim's `maxRounds` default;
  exhaustion fails with an explanatory internal error, which also bounds the `requestState`-only load-shedding form
  that cannot make progress in-process.

The shim is on by default, matching the TypeScript SDK; `Server.new(input_required_legacy_shim: false)` restores
the strict rejection. Requests without a session (no notification path back to a client) and all modern requests
are untouched: the modern wire keeps returning the `input_required` result for the client driver.

Resolves #382.

## How Has This Been Tested?

`bundle exec rake` passes: 1691 test runs / 4381 assertions with zero failures and RuboCop clean.
The conformance baseline check passes on both legs (the frozen 2025-11-25 wire is where the shim lives),
and the modern `input-required-result-*` scenarios stay green, confirming the modern path is untouched.

New `test/mcp/server_input_required_legacy_shim_test.rb` covering: completion through
a real elicitation with the SEP-2260 association asserted on the transport, replay exposing
the collected responses and the raw `requestState`, sealing bypass under `RequestStateSecurity`,
`prompts/get` and `resources/read` shimming, multi-round collection, the round cap, the capability gate failing
before client contact, the URL-mode leg gaining a synthesized `elicitationId` (and a handler-supplied
one riding unchanged), a client answering a fulfilment leg with an error failing the original request,
the opt-out, session-less rejection, and modern requests staying untouched.

The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged.

## Breaking Changes

Only for handlers that returned `InputRequiredResult` to legacy clients THROUGH A SESSION and relied
on the internal-error rejection: those now drive a real fulfilment round trip by default.
No such handler could have worked end to end before (the request always failed), so this is the intended upgrade;
`input_required_legacy_shim: false` restores the previous behavior. Everything else is unchanged.
2026-08-14 02:46:38 +09:00
Koichi ITO
2fb9ab8e6a Drive multi round-trip input_required results on the client per SEP-2322
## Motivation and Context

Final step of SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322) for the 2026-07-28 MCP spec release.
The client so far only recognized `input_required` results by raising `InputRequiredError`; this adds the resume loop,
mirroring the Python SDK's high-level `Client` driver and the TypeScript SDK's `runInputRequiredDriver`.

- The driver answers embedded requests with the handlers already registered through `on_elicitation`,
  `on_sampling`, and the new `on_roots`. There is no second registration path: a server can ask for input two
  ways, as a real request mid-call and as an entry in an `input_required` result, and one registration covers
  both. This follows the TypeScript driver, which dispatches "to the client's already-registered handlers
  (elicitation, sampling, roots - one generic engine, no per-feature API)".
- `on_elicitation` and `on_sampling` no longer refuse a transport without `on_server_request`. They record
  the handler either way and wire it to the transport only when it can carry server-to-client requests. Refusing
  would have locked stdio clients, and every 2026-07-28 connection, out of the only route the spec leaves:
  the modern lifecycle forbids server-to-client requests, so an embedded request is how it asks.
- `MCP::Client.new` gains `input_required_max_rounds:` (default 10, the TypeScript and Python default).
- Once a handler is registered, `call_tool`, `get_prompt`, and `read_resource` resume automatically:
  each `inputRequests` entry is fulfilled by the matching handler and the ORIGINAL request is re-issued with
  `inputResponses` under the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg
  (modern envelope stamping happens in the transports per send, so every leg carries the SEP-2575 triple).
  A `requestState`-only result (load shedding) retries after an exponential backoff from 50ms to a 250ms cap,
  matching the Python SDK; every leg counts against the round cap, whose exhaustion raises `InputRequiredError`
  carrying the last result.
- A requested kind without a matching handler falls back to the manual path by raising `InputRequiredError`,
  and clients with no handlers keep the exact pre-existing behavior. For manual driving,
  `call_tool` / `get_prompt` / `read_resource` gain `input_responses:` and `request_state:` keyword arguments
  that ride at the params top level. Non-MRTR methods such as `tools/list` keep raising unconditionally.

The conformance client rides the same change: it derives its lifecycle from the wire version the harness names per run
(`MCP_CONFORMANCE_PROTOCOL_VERSION`), connecting with `mode: :modern` for the 2026 draft and keeping the pinned legacy
handshake through 2025-11-25, retrying a modern connect once when the server rejects the first request with `-32022`
naming its supported versions. New scenario branches drive `sep-2322-client-request-state` through this driver
(an accepting `on_elicitation` handler plus the four `test_mrtr_*` tools), `request-metadata`, both SEP-2243 header
scenarios, and `json-schema-ref-no-deref`. The expected-failures baseline drops the six scenarios this stack resolves,
leaving only the not-scored extensions and the post-anchor `json-schema-2020-12-preservation`.

Verified end-to-end over stdio: a modern connection (`connect(mode: :modern)` with declared form-elicitation capability)
against a server whose tool returns `InputRequiredResult` under `RequestStateSecurity` sealing completes in two legs with
the handler supplying the answer, and a handler-less client receives `InputRequiredError` with the sealed opaque state.

Part of #382.

## How Has This Been Tested?

New tests in `test/mcp/client_test.rb` (Mocha sequenced transports) cover: the two-leg happy path asserting preserved original params,
key-matched `inputResponses`, byte-exact `requestState` echo, and a fresh id per leg; the exponential backoff sequence for
`requestState`-only legs (stubbed `sleep`); round-cap exhaustion raising `InputRequiredError` with the last state; the unhandled-kind
fallback; the manual `input_responses:`/`request_state:` keyword arguments; and the `prompts/get` driver path. The existing SEP-2322
recognition tests pin the no-handler behavior unchanged.

Three tests cover the shared-registration design specifically: the driver resolving an embedded request on a transport that
has no `on_server_request` at all, an `on_roots` handler answering an embedded `roots/list`, and `on_elicitation` and
`on_sampling` accepting a registration on such a transport rather than raising, which replaces the two tests that pinned
the old refusal.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes, plus the stdio end-to-end script described above.

With the separate SEP-2243 mirroring change applied alongside, every scored scenario of the frozen 2026-07-28 client leg passes
(`sep-2322-client-request-state` 5/5, `request-metadata` 5/5 with no version-retry warning, `http-custom-headers` 18/18,
`http-invalid-tool-headers` 11/11), and the `--requirements` client legs at both revisions report failures only
in not-scored scenarios. The full conformance task passes its baseline check on both legs.

## Breaking Changes

`on_elicitation` and `on_sampling` no longer raise `ArgumentError` on a transport without `on_server_request`; they
register the handler for the `input_required` route instead. Code that relied on the refusal to detect an unsuitable
transport needs another check. Everything else is additive: the new keyword arguments default to `nil`, and with no
handler registered every code path is identical to before.
2026-08-14 02:04:21 +09:00
Koichi ITO
416b7e8a6e Bound server-to-client requests with a timeout
## Motivation and Context

The specification asks implementations to establish timeouts for all sent requests,
"to prevent hung connections and resource exhaustion", to cancel the request once the deadline passes,
and to let SDK users configure the deadline per request. This SDK did none of the three: `send_request` blocked
on `Queue#pop` with no deadline, so a client that opens a session, triggers a handler that elicits input,
and then simply never answers holds that worker thread for good. A handful of such requests exhausts
a Rack server's thread pool. The existing escape hatches are weak: the idle reaper waits 1800 seconds
by default and refreshes on any traffic for the session, so a client that keeps sending other requests never trips it,
and only the GET stream has a keepalive to notice a vanished peer.

`StreamableHTTPTransport` now waits `server_to_client_request_timeout:` seconds
(`DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT`, 600) for a response, and `timeout:` overrides it per request through
`ServerContext` and `ServerSession` down to the transport. The option spells out its direction because
the same constructor already takes `max_request_bytes:` for the requests arriving the other way.
Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for
the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default is wrong".
Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's
own model producing a sample. The Python SDK leaves the deadline unset and bounds nothing by default.

On expiry the transport sends `notifications/cancelled` for the request it stopped waiting on, so a late answer
does not act on something the server abandoned, and raises the new `MCP::Server::RequestTimeoutError`.
Both reference SDKs send the same courtesy cancel on timeout, and it is what the revision governing this path asks
for: 2025-11-25 lets either side cancel and tells the sender to "issue a cancellation notification for that request
and stop waiting". Only that revision and earlier reach the wait, since the modern lifecycle forbids these requests
and its sessionless requests never register the session `send_request` looks up.

Uncaught in a handler, the error answers the client's originating request with `-32001` rather than a generic
internal error. The code is not spec-allocated, but it sits in the implementation-defined server range and is what
the Python SDK reports for this condition, so a peer that recognizes it there reads the same meaning here.

The wait itself moves from `Queue` to a small `MCP::Server::PendingResponse`: `Queue#pop` only accepts a `timeout:`
on Ruby 3.2 and later and this gem supports 2.7, so the wait is a `ConditionVariable` with a monotonic deadline.
It keeps the `push`/`pop` names, leaving the three resolving call sites (a client response, a cancellation, session teardown)
unchanged, and keeps first-writer-wins so a cancellation racing a real response still cannot overwrite it.
The expiry block runs after the lock is released, since it cancels the request and would otherwise re-enter
the same non-reentrant mutex.

stdio is untouched and ignores `timeout:`, which the README now states. Its `send_request` waits inside
a `$stdin.gets` loop rather than on a queue, and the process model differs enough (the server owns the client
process, whose exit surfaces as EOF) that it deserves its own change; `forward_to_transport` already filters
`timeout:` out for transports that do not declare it, so custom transports and stdio keep their existing signatures.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover a request timing out with
the transport default, `timeout:` overriding it per request, the error code it reports, expiry reaching the peer
as `notifications/cancelled`, a response arriving before the deadline still returning normally, and
the constructor rejecting a non-positive `server_to_client_request_timeout`.
`test/mcp/server_session_request_timeout_test.rb` covers the deadline reaching the transport from all five
`ServerSession` entry points, and being dropped for transports that never declared it.
`test/mcp/server_context_test.rb` covers the same through `ServerContext`, including an omitted `timeout:`
not being forwarded at all. The pre-existing `send_request` tests (the response path, cross-session rejection,
cancellation, the cancel/response race, client errors, and session teardown) pass unchanged against the new
wait primitive.

`bundle exec rake` is green, and both conformance legs pass their baseline check. `PendingResponse` was exercised
directly on Ruby 2.7.0, the minimum this gem supports and the reason the wait avoids `Queue#pop(timeout:)`:
the deadline fires on time, a pushed value is delivered, the first writer wins, and the expiry block can resolve
the same object without deadlocking.

## Breaking Changes

Server-to-client requests that previously waited forever now fail after ten minutes by default. This is
the incompatible-but-required kind of change VERSIONING.md admits into a minor release: the unbounded wait deviates from
the specification and is the resource-exhaustion path it exists to prevent. Handlers that legitimately wait longer pass
a larger `timeout:`; the release notes and CHANGELOG entry need to call this out and point at that knob.
2026-08-13 23:16:59 +09:00
Koichi ITO
b09708365b
Merge pull request #499 from koic/stamp_cache_hints_on_modern_results
Stamp the required cache hints on modern cacheable results per SEP-2549
2026-08-13 23:14:17 +09:00
Koichi ITO
ebed930927
Merge pull request #495 from koic/subscriptions_listen
Serve the `subscriptions/listen` notification stream per SEP-2575
2026-08-13 23:05:35 +09:00
Koichi ITO
c40aa9cc59 Serve the subscriptions/listen notification stream per SEP-2575
## Motivation and Context

Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream;
`subscriptions/listen` replaces it as a long-lived POST that opts in to server change notifications.
The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035);
the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321.

Wire behavior, per the draft schema:

- The request carries a REQUIRED `notifications` filter (`SubscriptionFilter`): `toolsListChanged`,
  `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (URI list, replacing
  the legacy `resources/subscribe` RPC). Every type is opt-in; the server MUST NOT send types
  the client did not request.
- The first stream message is `notifications/subscriptions/acknowledged`, reporting the subset of
  requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery
  (`listChanged`, `subscribe`), the same derivation `server/discover` uses for its era-aware capability stripping;
  the mere presence of a primitive's capability is not enough.
- Every notification delivered on the stream carries the correlating `io.modelcontextprotocol/subscriptionId`
  (the listen request id) in `_meta`.
- A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` response before closing the stream,
  stamped with the REQUIRED 2026-07-28 `resultType` at its construction site (it never passes through the dispatch path);
  an abrupt disconnect sends nothing.

Implementation:

- `StreamableHTTPTransport` intercepts `subscriptions/listen` on the modern path (after header and envelope validation)
  and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream.
  Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks into `send_notification` ahead of
  the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers.
  The matching snapshot is taken under the transport mutex, but stream writes happen outside it,
  matching the legacy delivery paths: a slow or stalled subscriber must not block the transport.
  Duplicate subscription ids close the new stream instead of double-registering.
- Concurrent listen streams are capped (`max_listen_subscriptions:`, default 1000, `nil` to opt out);
  a listen request past the cap is rejected with HTTP 503, like the `max_sessions` guard against session floods.
  Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain
  unbounded connections.
- Each stream is kept alive by an SSE comment frame written every `listen_keepalive_interval:` seconds
  (default 15, matching the TypeScript SDK; `nil` to opt out when an upstream proxy pings the stream).
  A silently dropped connection would otherwise hold its capped slot until the next fan-out write failed,
  so on a quiet server the cap would ratchet down permanently; the periodic write detects the dead peer
  and frees the slot. A comment frame cannot corrupt an interleaved notification's JSON, and the write
  happens outside the transport mutex, reusing the legacy GET stream's keepalive mechanism.
- Every SSE response now carries `x-accel-buffering: no`, which the spec asks of SSE streams and both
  reference SDKs send: a buffering reverse proxy would hold events back instead of delivering them as
  they are written, and on a listen stream it would also swallow the keepalive frames a dropped peer
  is detected by. The header rides the shared `SSE_HEADERS`, so the legacy streams gain it too.
- stdio does not serve the stream, matching the Python SDK's stream-pair behavior: no server handler is registered,
  so the method answers `-32601`.
- `Server#discover` becomes era-aware about notification delivery: `listChanged`/`subscribe` capability flags
  promise delivery over `subscriptions/listen` streams in the modern lifecycle, so they are stripped when
  the transport does not serve that RPC (the new `Transport#serves_subscriptions_listen?` seam, true for `StreamableHTTPTransport`).
- The conformance fixture defines the diagnostic triggers the `server-stateless` scenario calls
  (`test_trigger_tool_change` / `test_trigger_prompt_change`): each broadcasts its list-changed notification
  to the listen streams and returns, mirroring the suite's TypeScript reference fixture, which mutates nothing either.

Out of scope, noted for follow-ups: an external event bus for multi-worker deployments, and the client-side listen driver
(the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047).

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and
for capability entries lacking the delivery flag), the `notifications` filter and envelope requirements (400 responses),
opt-in-only delivery with the correlating `subscriptionId`, URI-scoped `resources/updated` delivery, per-subscription ids across
concurrent streams, the graceful close result carrying `resultType: "complete"`, duplicate-id rejection, the 503 past
the concurrent stream cap, the keepalive (writing the comment frame outside the mutex, freeing a dead peer's slot,
no thread when the interval is `nil`, and the positive-or-nil interval validation), and `x-accel-buffering: no`
on both the listen stream and the legacy GET stream.
`test/mcp/server/transports/stdio_transport_test.rb` asserts `-32601` over stdio,
and `test/mcp/server_test.rb` covers the era-aware capability stripping in `server/discover` for both transport kinds.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

Against the conformance fixture server at `--spec-version 2026-07-28`, the `server-stateless` subscription checks
all report SUCCESS: the acknowledgement, `subscriptionId` tagging, and filter-containment MUSTs, plus both
list-changed SHOULD checks driven by the new trigger tools. The `--requirements 2025-11-25` server leg
passes 78/78, unchanged.

## Breaking Changes

None. The method was previously unhandled (`-32601` everywhere); the only observable change to existing responses is that
`server/discover` no longer advertises `listChanged`/`subscribe` flags on transports that cannot deliver those notifications in
the modern lifecycle, which `server/discover` has not shipped in a gem release with anyway.
2026-08-11 00:05:39 +09:00
Koichi ITO
f0c9665383
Merge pull request #498 from koic/mirror_x_mcp_header_params_into_headers
Mirror `x-mcp-header` tool parameters into `Mcp-Param-*` headers per SEP-2243
2026-08-10 12:20:19 +09:00
Koichi ITO
6dfef81a75
Merge pull request #496 from koic/request_state_security
Add opt-in `requestState` sealing via `RequestStateSecurity`
2026-08-10 12:19:56 +09:00
Koichi ITO
5864b8431c Stamp the required cache hints on modern cacheable results per SEP-2549
## Motivation and Context

The 2026-07-28 spec revision makes the SEP-2549 `ttlMs` / `cacheScope` cache hints REQUIRED members of
the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`,
and `resources/read`). The SDK's existing emission is opt-in (`Server.new(ttl_ms:, cache_scope:)`),
so servers that never opted in violate the 2026-07-28 wire schema, and the conformance suite fails
those scenarios on wire-schema validation (`ListToolsResult: must have required property 'cacheScope' / 'ttlMs'`)
plus all eight checks of the `caching` scenario.

The dispatch path now fills the hints centrally for results of requests carrying the SEP-2575 `_meta` envelope,
next to the SEP-2322 `resultType` stamp: a cacheable result that does not already carry both fields gets
the configured `ttl_ms` / `cache_scope` values, with `ttlMs: 0` (do not cache) and `cacheScope: "private"`
filling anything unset. Values already in the result win, preserving per-result overrides.
Only complete results are stamped: an SEP-2322 `input_required` round trip of `resources/read` is not
a cacheable final result and stays hint-free.

The unset-scope fill is `"private"` because the spec names no default scope and `"private"` is the side
that cannot leak a user-dependent `resources/read` result through a shared cache; it is also
the unconfigured default of the TypeScript SDK (`cacheScope: 'private'`), the Python SDK (`CacheHint.scope`),
and this SDK's own `server/discover`. The opt-in emission of `apply_cache_metadata` used to fill a missing
scope as `"public"` when only `ttl_ms` was configured and now fills `"private"` for the same reason,
so both paths agree. Stable protocol versions otherwise keep the opt-in emission unchanged.

## How Has This Been Tested?

`bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: modern `tools/list`
and `resources/read` results carry the default hints, configured values win over the defaults,
non-cacheable modern results (`ping`) stay untouched, and legacy cacheable results keep the opt-in emission.
The opt-in emission tests assert the `"private"` fill for a configured `ttl_ms` without a scope.
The previously failing conformance scenarios (`tools-list`, `resources-read-text`, and `caching`) pass at
`--spec-version 2026-07-28` against the conformance fixture server, and the 2025-11-25 leg is unchanged.

## Breaking Changes

The modern-path emission is additive: it applies only to results of requests carrying the SEP-2575
modern `_meta` envelope. One deliberate change reaches stable versions: a server configured with
`ttl_ms:` but no `cache_scope:` now emits `cacheScope: "private"` instead of `"public"` on its opt-in results;
declaring `cache_scope: "public"` explicitly restores the shared-cacheable scope.
2026-08-09 01:25:08 +09:00
Koichi ITO
fee2710955
Merge pull request #493 from seunghan91/expose-server-context-in-instrumentation
Expose the user-defined server_context in instrumentation data
2026-08-09 01:10:36 +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
e6b5a9b5e3 Add opt-in requestState sealing via RequestStateSecurity
## Motivation and Context

The SEP-2322 `requestState` continuation string leaves the server, sits in the client's hands,
and comes back as client-controlled input. Without protection a client can read the server's continuation state,
tamper with it (for example forging an already-answered elicitation), or replay a state issued for one call against
another tool, other arguments, or another server. The multi-round-trip documentation of the Python SDK is explicit
that the echo must be treated as untrusted input; its high-level server seals the state by default via `RequestStateBoundary`.

New `MCP::Server::RequestStateSecurity` (OpenSSL standard library only) brings that protection to this SDK as an opt-in:

- `seal` encrypts the plaintext state with AES-256-GCM (clients cannot read it, not merely verify it) inside a claims
  envelope binding an expiry window (`ttl:`, default 300 seconds, re-sealed each round), the originating method and target
  (tool/prompt name or resource URI), a digest of the originating arguments (stringified and sorted recursively,
  so symbol/string parses of identical JSON digest identically), and an optional `audience:`. The token format is
  `v1.<base64url(iv || ciphertext || tag)>` with the version prefix bound as GCM associated data,
  following the Python SDK's `AESGCMRequestStateCodec`.
- `unseal` verifies every claim fail-closed and raises `InvalidStateError` on tampering, expiry, or any mismatch.

Passing an instance via `Server.new(request_state_security:)` makes the seal/unseal transparent: issuance seals
the `requestState` of an outgoing `input_required` result, and dispatch unseals the echoed token for `tools/call`,
`prompts/get`, and `resources/read` before any handler runs, so `server_context.request_state` always reads
the plaintext the handler wrote. A tampered, expired, or cross-call echo is rejected as `-32602`
with "Invalid or expired requestState", matching the Python SDK's frozen error.

Without the option the state crosses the wire exactly as the handler wrote it (the Python low-level Server behavior);
the README documents that this is then the handler author's responsibility, and that multi-process deployments
must share the key across workers.

The conformance fixture opts in with a random per-boot key, both to dogfood the feature and because
the tampered-state scenario of the 2026-07-28 conformance requirements retries an MRTR request with
a corrupted `requestState` and requires a JSON-RPC error; without integrity-checked state the fixture
cannot detect the corruption. Every MRTR round trip completes within one server process,
so no key persistence is needed. Sealing is transparent to the fixture tools, whose `requestState` plaintext
round-trips unchanged, and the new `test_input_required_result_tampered_state` tool is therefore
a plain MRTR elicitation flow; the tampered echo is rejected with `-32602` by the server-level unsealing
before dispatch ever reaches it.

Refs #382.

## How Has This Been Tested?

New `test/mcp/server/request_state_security_test.rb` covers constructor validation,
the seal/unseal round trip (opaque `v1.` token, plaintext not visible), expiry via time travel,
fail-closed rejection of method/target/digest/audience mismatches, tampered and malformed tokens,
and tokens sealed under a different key.

New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope:
transparent sealing on issuance and plaintext restoration on the retry leg, `-32602` for
tampered echoes and for echoes replayed against different arguments, and the pass-through
default when `request_state_security:` is not set.

Against the fixture server at `--spec-version 2026-07-28` (run with `@modelcontextprotocol/conformance@alpha`;
the 2026-07-28 scenarios are not yet in a stable conformance release), `sep-2322-reject-tampered-state` reports SUCCESS,
and the other 13 `input-required-result-*` scenarios stay green with sealing active,
confirming that sealed states round-trip through the suite's retries without false rejections
(the seal binds `method`, target, and an arguments digest, and the suite echoes all three unchanged).
The `--requirements 2025-11-25` server leg still passes.

## Breaking Changes

None. The feature is opt-in via a new keyword argument that defaults to `nil`, in which case behavior is byte-identical to before.
2026-08-08 18:54:14 +09:00
Koichi ITO
b03da23dd3
Merge pull request #482 from koic/elicitation_enum_schema
Add `MCP::Elicitation::EnumSchema` builders for SEP-1330
2026-08-08 15:29:55 +09:00
Koichi ITO
14bf3a7091 Stamp the required resultType on modern results per SEP-2322
## Motivation and Context

SEP-2322 (merged for the 2026-07-28 spec release) makes `resultType` a REQUIRED member of every result
a 2026-07-28 server returns: "Servers implementing this protocol version MUST include this field",
with absence tolerated only when the result comes FROM an earlier protocol version.
The client-side vocabulary landed earlier (`MCP::ResultType`, treating an absent value as `"complete"`),
but every ordinary modern result the server returns is unstamped, which violates the MUST for `tools/list`,
`tools/call`, `ping`, and every other standard result on the modern wire.
The conformance suite's 2026-07-28 requirements fail each of those scenarios on wire-schema validation
(`CallToolResult: must have required property 'resultType'`).

The dispatch path now stamps `resultType: "complete"` centrally: any Hash result of a request carrying
the SEP-2575 `_meta` envelope that does not already carry a `resultType` gets the standard value merged in.
Results that already carry a discriminator keep it, notifications and cancellation-suppressed responses
are untouched, and legacy results stay unstamped because pre-2026 clients do not know the field.
`server/discover`, which is exempt from the `_meta` envelope (pre-version discovery),
stamps its `DiscoverResult` at its construction site instead.

## How Has This Been Tested?

`bundle exec rake test` passes with zero failures. New tests in `test/mcp/server_test.rb`: `tools/list`,
`ping`, and `tools/call` results carry `resultType: "complete"` on modern requests and no `resultType`
on legacy requests; `server/discover` carries the stamp. The conformance scenarios previously failing
on the missing property (`tools-call-simple-text`, `prompts-get-simple`, `completion-complete`, and
the other tools/prompts scenarios) pass at `--spec-version 2026-07-28` against the conformance fixture server.

## Breaking Changes

None. The stamp applies only to results of requests carrying the SEP-2575 modern `_meta` envelope;
results on stable protocol versions through 2025-11-25 are byte-for-byte unchanged.
2026-08-08 15:12:18 +09:00
Koichi ITO
6e2b7a1971 Let Handlers Return Multi Round-Trip input_required Results per SEP-2322
## Motivation and Context

SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 MCP spec release)
replaces server-initiated JSON-RPC requests, which the stateless modern lifecycle (SEP-2575) forbids,
with multi round-trip results: a `tools/call`, `prompts/get`, or `resources/read` handler returns
`resultType: "input_required"` with `inputRequests` (server-assigned keys mapping to `elicitation/create`,
`sampling/createMessage`, or `roots/list` request shapes) and/or an opaque `requestState`,
and the client retries the ORIGINAL request with `inputResponses` under the same keys plus the echoed state.
The client-side recognition landed earlier (`MCP::ResultType`, `Client::InputRequiredError`);
this adds the server side, mirroring the Python SDK's low-level Server (python-sdk#2967/#2986)
and the TypeScript SDK's umbrella 2026-07-28 work.

- New `MCP::Server::InputRequiredResult` value object: construction validation
  (at least one of the two fields, embedded methods restricted to the three shapes), `to_h` wire serialization,
  and the capability mapping shared with the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`
  (url-mode elicitation requires `elicitation.url`, form otherwise; sampling with `tools`/`toolChoice` requires
  `sampling.tools`; `roots/list` requires `roots`), including the 2025 back-compat rule that a bare `elicitation: {}`
  declaration implies form support.
- Handlers return the object through the existing paths: both branches of `call_tool_with_args` and
  `call_prompt_template_with_args` pass it through instead of calling `.to_h`, `call_tool` skips
  output schema validation and the structured-content fallback for it (output schema validation would otherwise
  run against a `nil` `structuredContent`), and the `resources/read` dispatch skips the `contents` wrapping and
  SEP-2549 cache-hint stamping.
- One central hook in the dispatch lambda gates and serializes the result, after the cancellation check
  so cancelled requests stay suppressed: a legacy request (no SEP-2575 envelope) gets an internal error,
  because pre-2026 clients treat an unknown `resultType` as a final result; embedded requests exceeding
  the request's declared client capabilities get `-32021` with the full merged `requiredCapabilities` set.
- `MCP::ServerContext` gains `input_responses`, `request_state`, and the key-tolerant `input_response(key)` reader.
  The retry fields are params-top-level siblings of `name`/`arguments`/`uri` (not `_meta`),
  so only handlers that opt in to `server_context:` can participate, matching the envelope readers' access model.
  The server holds no memory between rounds: handlers re-run from the start on every retry
  (deterministic replay, as in the Python SDK).

The 2026-07-28 conformance requirements score 14 `input-required-result-*` scenarios and the capability
checks of `server-stateless`, and each drives a diagnostic fixture tool the server under test must define.
The fixture defines the tools those scenarios call, mirroring the suite's TypeScript reference fixture
(`examples/servers/typescript/everything-server.ts`) in tool names and `inputRequests` keys:

- `test_input_required_result_elicitation` / `_sampling` / `_list_roots`: one embedded request each,
  answered on the retry round (also exercised by the missing-response, result-type, ignore-extra-params,
  and validate-input scenarios)
- `test_input_required_result_request_state`: stamps an opaque `requestState` and completes only after
  it round-trips
- `test_input_required_result_multiple_inputs`: all three embeddable methods in one result
- `test_input_required_result_multi_round`: two elicitation rounds with per-round `requestState`
- `test_input_required_result_capabilities`: builds `inputRequests` from the declared client capabilities,
  so a sampling-only client is never asked to elicit
- prompt `test_input_required_result_prompt`: the non-tool MRTR flow over `prompts/get`
- `test_missing_capability`: guards on `require_client_capability!(:sampling)`, giving
  the `server-stateless` capability checks a target that answers -32021 with HTTP 400

`test_input_required_result_tampered_state` is deliberately absent: rejecting a tampered `requestState` needs
the sealing machinery of the request-state security change, and its fixture tool lands there.
Sealing of the echoed `requestState` (it arrives as client-controlled input) and the client-side auto-fulfillment loop
follow in the next changes.

Refs #382.

## How Has This Been Tested?

New `test/mcp/server/input_required_result_test.rb` covers construction validation, key normalization and freezing,
the wire shape of `to_h`, the full capability mapping, missing-capability subtraction with symbol/string declarations,
and the implied-form back-compat rule.

New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: issuance wire shape for all three methods,
the retry leg exposing `input_responses`/`request_state`/ `input_response(key)` to the handler, the legacy-request internal error,
`-32021` with the merged `requiredCapabilities` data, output-schema validation bypass under `validate_tool_call_results: true`,
and `resources/read` results staying unwrapped without `ttlMs`/`cacheScope` stamping even when the server configures cache hints.

Against the fixture server at `--spec-version 2026-07-28`, all SEP-2322 checks of the 13 covered scenarios pass,
and `server-stateless` reports `sep-2575-server-rejects-undeclared-capability` and
`sep-2575-missing-capability-http-400` as SUCCESS. The `wire-schema-valid` failures on rounds that reach
a complete result come from the missing `resultType` stamp, which is the separate stamping change's job.
The `--requirements 2025-11-25` server leg still passes 78/78, and `bundle exec rake` is green.

## Breaking Changes

None. The serialization seams only branch on a return type that previously could not occur, all new keyword arguments default to `nil`,
and requests that carry no `inputResponses`/`requestState` behave exactly as before.
2026-08-08 15:07:16 +09:00
seunghan Kim
14e02da475 Expose the user-defined server_context in instrumentation data
APM integrations need to tag a span with the subject of the request, but the
user-defined `server_context` hash passed to `Server.new` never reaches
`around_request` or `instrumentation_callback`. The exception reporter's
`server_context` argument is a different value — it describes where a failure
occurred (`{ request: ... }`, `{ notification: ... }`), not who made the call —
so there is currently no way to read the request subject from a hook.

Add `Configuration#instrument_server_context`, off by default. When enabled,
`instrument_call` puts the host's `server_context` into the instrumentation data
before invoking `around_request`, so it is readable both before and after
`request_handler.call`.

The flag is opt-in rather than unconditional because the hash is
application-supplied and may hold values a tracing backend should not receive.
Hosts that do not expose a `server_context` reader are unaffected.

`instrument_call` reads it through `self.server_context`: the existing
`server_context:` keyword shadows the reader, and the value wanted here is the
user-defined hash, not the reporter context.

Follows the same shape as the earlier additions of `tool_arguments` (#218) and
`client` (#221) to this hash.
2026-08-08 08:33:07 +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
Koichi ITO
b54eebac75 Add MCP::Elicitation::EnumSchema builders for SEP-1330
## Motivation and Context

MCP 2025-11-25 (SEP-1330) replaces the ad-hoc `enumNames` convention with four standards-based enum schema variants
for elicitation requests: titled/untitled single-select and titled/untitled multi-select. The TypeScript SDK ships
typed Zod definitions for each shape (`packages/core/src/types/schemas.ts`), and the Python SDK relies on hand-built
schemas with the same shapes in its conformance fixtures.

Adds builder class methods on `MCP::Elicitation::EnumSchema` that produce the canonical JSON-Schema shapes for each variant,
plus a `legacy_titled` builder retained for backward compatibility with clients that have not yet migrated.
Each constructor returns an `EnumSchema` instance whose `to_h` produces the property-value Hash to pass through
`requested_schema:` on `ServerSession#create_form_elicitation`.

Each builder maps one-to-one to the spec's schema type names in `schema/2025-11-25/schema.ts`,
which the TypeScript and Python SDKs also carry verbatim in their type layers:

| Spec type (schema.ts)                             | Builder                                         |
|---------------------------------------------------|-------------------------------------------------|
| `UntitledSingleSelectEnumSchema`                  | `untitled_single_select`                        |
| `TitledSingleSelectEnumSchema`                    | `titled_single_select`                          |
| `UntitledMultiSelectEnumSchema`                   | `untitled_multi_select`                         |
| `TitledMultiSelectEnumSchema`                     | `titled_multi_select`                           |
| `LegacyTitledEnumSchema` (deprecated in the spec) | `legacy_titled`                                 |
| `EnumSchema` (the union of all five)              | the `MCP::Elicitation::EnumSchema` class itself |

These shapes stay relevant on the stateless 2026-07-28 lifecycle: elicitation is not deprecated by SEP-2577
and reaches modern clients as `elicitation/create` requests embedded in SEP-2322 `input_required` results,
whose `requestedSchema` uses the same variants. The README documents that pairing; the example references
`MCP::Server::InputRequiredResult`, so this change should merge after the SEP-2322 server-side PR.

The conformance server now builds the enum schemas of its `test_elicitation_sep1330_enums` tool
(and the `status` enum of the SEP-1034 defaults tool) through these builders instead of hand-written hashes,
so the conformance run exercises the exact schemas the SDK produces for users.

## How Has This Been Tested?

`test/mcp/elicitation/enum_schema_test.rb` covers each of the five shapes with happy-path emission,
`default` propagation, `title` / `description` propagation, and the four ArgumentError cases
(empty values, missing `:value`, missing `:title`, mismatched `value_titles` length). Two additional regression tests
assert that empty-but-non-nil defaults survive `to_h`:

- `EnumSchema.untitled_single_select(default: "")` keeps the empty string in the emitted Hash
- `EnumSchema.untitled_multi_select(default: [])` keeps the empty array in the emitted Hash

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes; the `elicitation-sep1330-enums`
and `elicitation-sep1034-defaults` conformance scenarios pass with the builder-produced schemas,
confirming they emit the same wire shapes as the previous hand-written hashes.

## Breaking Changes

None. The change is purely additive: new module `MCP::Elicitation::EnumSchema` with new class methods.
Existing callers of `ServerSession#create_form_elicitation` that pass raw hashes continue to work unchanged.
2026-08-05 12:26:13 +09:00
Koichi ITO
a1fd3d4283 Document non-text content types, binary resources, and elicitation schema options
## Motivation and Context

The SDK tiering system (SEP-1730) requires Tier 1 SDKs to document all non-experimental features
with examples. A documentation coverage audit against the canonical feature list found that the README.md
only showed `type: "text"` content everywhere, leaving several implemented features invisible to users:

- Tool responses with image, audio, and embedded resource content (`MCP::Content::Image` / `Audio` / `EmbeddedResource`)
- Prompt messages carrying image or embedded resource content
- Reading binary resources via `MCP::Resource::BlobContents`
- Reading resources through a registered resource template URI
- Elicitation `requested_schema` flat-object restrictions and client-side validation behavior
- Elicitation default values (SEP-1034) and enum values with `enumNames` (SEP-1330)

All of these are existing SDK capabilities; the conformance suite already exercises them.
This change documents each with prose and a runnable-style example, closing every documentation gap
that does not require new code. Legacy SSE transport remains undocumented because the SDK intentionally
does not implement it.

## How Has This Been Tested?

Documentation-only change. Every code example was written against the current public API in lib/
(`MCP::Content`, `MCP::Resource::TextContents` / `BlobContents`, `MCP::Prompt::Message`, `create_form_elicitation`)
and cross-checked with the working implementations in conformance/server.rb, which pass the conformance suite.

## Breaking Changes

None. This only adds sections to README.md.
2026-07-27 16:41:01 +09:00
Koichi ITO
cc219a2151
Merge pull request #458 from koic/feature_sampling_client
Support client-side sampling via `MCP::Client#on_sampling`
2026-07-16 01:04:28 +09: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
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
f3d6da2371 Support client-side sampling via MCP::Client#on_sampling
## Motivation and Context

The MCP specification defines `sampling/createMessage` as a server-to-client request
that lets a server ask the client to generate an LLM completion, so the server can leverage
the client's model access without its own API keys.
The Ruby SDK already supports the server side (sending the request) and, on the client side,
the generic server-to-client request infrastructure introduced for elicitation
(the standalone GET SSE listening stream and `on_server_request` dispatch).
This adds the client-side `sampling/createMessage` handler on top of that infrastructure.

## Changes

- Add `MCP::Client#on_sampling`, a thin wrapper that registers a `sampling/createMessage` handler
  via `transport.on_server_request`, mirroring `on_elicitation`. The handler receives the request params
  and returns a `CreateMessageResult`-shaped Hash sent back as the JSON-RPC result.
- Document client-side sampling in the README, including the `sampling` (and `sampling.tools`)
  capability declaration and the human-in-the-loop guidance from the specification.

## How Has This Been Tested?

- Unit tests for `on_sampling` covering handler registration on the transport
  and the error raised when the transport does not support server-to-client requests.
- An HTTP transport test that dispatches a `sampling/createMessage` server request delivered on
  the SSE stream and returns a `CreateMessageResult`.

## Deprecation Note

SEP-2577 deprecates sampling (along with roots and logging) starting with protocol version `2026-07-28`.
The deprecation warnings are added in https://github.com/modelcontextprotocol/ruby-sdk/pull/406.
Sampling remains fully supported under the current `2025-11-25` protocol version and stays available
throughout the spec's deprecation window, so the client side is implemented here for parity with
the server side and with the Python and TypeScript SDKs.

`MCP::Client#on_sampling` carries the same `@deprecated` annotation as the server-side senders
annotated in #429, tailored to the receiving side: the handler exists to interoperate with servers
that still send sampling requests during the deprecation window. The README section carries
the same note.

Ref: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
2026-07-15 01:51:41 +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
5f9c84df1b
Merge pull request #441 from koic/mcp_apps
Add Server-Side MCP Apps Helpers per SEP-1865
2026-07-12 02:17:49 +09:00
Koichi ITO
a793dde6cd
Merge pull request #440 from koic/mcp_2026_07_28_spec_associate_server_requests
Associate server-to-client requests with the originating client request per SEP-2260
2026-07-12 02:17:04 +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
c43ea6e51b
Merge pull request #433 from koic/mrtr_result_type
Recognize multi round-trip `input_required` results per SEP-2322
2026-07-12 02:15:01 +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
6ddcc49865 Associate server-to-client requests with the originating client request per SEP-2260
## Motivation and Context

SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.

The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):

- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
  emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
  on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
  deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
  and never falls back to the GET stream.

Resolves #381.

## How Has This Been Tested?

- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
  `create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
  "attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
  each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
  The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
  the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
  is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
  stays clean; their assertions are unchanged.

## Breaking Changes

None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
2026-07-12 02:10:16 +09:00
Koichi ITO
a1b7b00b22
Merge pull request #444 from koic/add_rails_example
Add Rails example app
2026-07-12 01:43:04 +09:00
Koichi ITO
b6df40b134 Support class-based Resource and ResourceTemplate definitions
## Motivation and Context

Resolve the design difference raised in issue #98: Tools and Prompts support class-based definitions
while Resources and Resource Templates were plain data objects requiring a `resources_read_handler` block
with manual URI routing.

`MCP::Resource` and `MCP::ResourceTemplate` now support the same class-level DSL as `MCP::Tool`
and `MCP::Prompt` (`uri`, `resource_name`, `title`, `description`, `icons`, `mime_type`, `annotations`,
`size`, `meta`, and `uri_template` / `resource_template_name` for templates), plus `define` factory methods
and `MCP::Server#define_resource` / `#define_resource_template`.

The default `resources/read` handler now routes requests automatically: an exact URI match calls
the class-based resource's `contents` method, and class-based resource templates match
simple RFC 6570 level 1 `{var}` expressions, passing extracted variables as keyword arguments.
`contents` can opt in to a `server_context:` keyword argument. When class-based entries are registered
and no match is found, the server responds with -32602 carrying the URI in error data per SEP-2164.

## How Has This Been Tested?

- Added regression tests covering the class-level DSL, URI template matching, auto-routing,
  `server_context:` opt-in, error responses, and backward compatibility of the no-op default.
- Verified end-to-end with a smoke script exercising exact-match reads, template-match reads,
  unknown URIs, and legacy instance-only setups.

## Breaking Changes

No. Instance-based registration, `resources_read_handler` precedence, and the no-op `[]` response for
setups without class-based entries are unchanged. Reassigning `Server#resources` now rebuilds
the internal URI index.

Closes #98
2026-07-10 22:39:42 +09:00
Koichi ITO
61233ed43b
Merge pull request #438 from koic/server_discover
Add `server/discover` and stateless lifecycle error codes per SEP-2575
2026-07-10 00:26:49 +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
16a6df340c Add Rails example app
## Motivation and Context

The README.md documents the "Rails (mount)" integration pattern, but the repository
had no runnable Rails example. This adds a minimal Rails application under examples/rails
that mounts StreamableHTTPTransport at /mcp and demonstrates class-based tools and
a resource with a read handler.

The MCP server is built in config/routes.rb rather than in an initializer because
Zeitwerk sets up autoloading after initializers run, so the tool classes in app/tools
cannot be referenced there.
Code reloading is disabled because the transport holds the server built at boot.
The app is booted with puma instead of rackup because rackup inserts `Rack::Lint`
in its development default, which rejects the capitalized response header names
that the transport currently emits.

Closes #44.

## How Has This Been Tested?

The curl walkthrough in examples/rails/README.md has been verified end to end against
the running app: initialize and the initialized notification, tools/list, tools/call for
both tools, resources/list, resources/read, the standalone SSE stream,
and session termination via DELETE.

## Breaking Changes

None. This only adds an example application and documentation links.
2026-07-09 01:37:58 +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
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
3546660531 Add a Session-Ownership Hook to StreamableHTTPTransport
## Motivation and Context

`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates
an incoming request by session existence and idle timeout only, with no binding to a client identity.
An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST
a `tools/call` on that session and the server executes it, silently poisoning the victim's session.
The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path,
so this is not covered by it.

Binding a session to a user is fundamentally the deploying application's responsibility:
the transport hands the server only the JSON-RPC body string and never receives the authenticated principal
(`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as
a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present.
This gives the Ruby SDK the seam to do the same:

- A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }`
  on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects
  with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses,
  not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel
  a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover.
  `initialize` is exempt because it establishes the session. An application that has an authenticated principal
  uses the validator to enforce that the caller owns the session; without one, ownership is not enforced.
- A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header
  is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when
  both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC)
  is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker.
  Enforcing ownership against a determined attacker requires the validator.

Stateless mode retains no sessions, so neither check applies to it.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs
from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed;
a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID;
the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests;
and stateless mode never consults the validator.

## Breaking Changes

None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both
the session and the request carry an `Origin` and they differ, so existing applications are unaffected.
2026-07-07 21:41:29 +09:00
Koichi ITO
dfdd4efc24 Merge upstream modelcontextprotocol/ruby-sdk main
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
2026-07-07 21:28:11 +09:00
Koichi ITO
cfccb7ca25
Merge commit from fork
Validate `Host` and `Origin` Headers to Prevent DNS Rebinding per MCP 2025-11-25
2026-07-07 21:16:03 +09:00
Koichi ITO
7d9fda4360 Add Server-Side MCP Apps Helpers per SEP-1865
## Motivation and Context

SEP-1865 "MCP Apps - Interactive User Interfaces for MCP" is Final on the Extensions Track:
(https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) an optional extension,
negotiated through the SEP-2133 `capabilities.extensions` mechanism, that lets a server ship
HTML user interfaces which the host renders for tool results.
The normative spec lives in the ext-apps repository (`specification/2026-01-26/apps.mdx`).
The Python SDK implements it in-SDK (python-sdk#3003, the `Apps` extension); the TypeScript
ecosystem ships it as the separate `@modelcontextprotocol/ext-apps` package on top of
the core SDK's SEP-2133 support.

Server-side support is a thin convention layer over primitives this SDK already has
(SEP-2133 extension capabilities, `_meta` on tools and resources, free-form resource URIs
and MIME types). New `MCP::Apps` module:

- Wire vocabulary constants shared with the reference implementations: `EXTENSION_ID`
  (`"io.modelcontextprotocol/ui"` - note `/ui`, not `/apps`), `RESOURCE_MIME_TYPE`
  (`"text/html;profile=mcp-app"`), `URI_SCHEME` (`"ui://"`), and the legacy flat meta key
  `RESOURCE_URI_META_KEY` (`"ui/resourceUri"`).
- `Apps.capability(mime_types:)` builds the `capabilities.extensions` fragment for
  `support_extensions` on the server or `connect` on the client.
- `Apps.ui_resource(uri:, name:, ...)` builds an `MCP::Resource` for a UI template,
  enforcing the spec's MUSTs (a `ui://` URI; the parameterized HTML MIME type by default).
- `Apps.tool_meta(resource_uri:, visibility:, meta:, legacy:)` builds the `_meta.ui.resourceUri`
  linkage (optionally also emitting the legacy flat alias, matching the reference server helper
  that keeps both shapes in sync), merged non-destructively into caller-supplied meta.
- `Apps.client_supports?(client_capabilities, mime_type:)` gates the text-only fallback
  the extension requires for clients that did not declare the capability (symbol/string tolerant).

Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge methods,
consent for UI-initiated calls) is HOST responsibility and out of scope for a server SDK;
a server only ever receives ordinary `resources/read` and `tools/call` requests.
The README section states this boundary.

Resolves #376.

## How Has This Been Tested?

New `test/mcp/apps_test.rb` pins the wire vocabulary and covers the capability fragment,
`ui_resource` scheme validation and MIME default, `tool_meta` non-destructive merging,
visibility, the legacy alias, and `client_supports?` across symbol/string keys, mimeTypes narrowing,
and missing declarations.

New integration tests in `test/mcp/server_test.rb` drive a full server: the `initialize` result
advertises the extension, tools/list serializes `_meta.ui.resourceUri`, `resources/read` serves
the HTML template, and `client_supports?` distinguishes hosts with and without the declared extension.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Also verified end-to-end
over stdio: a client connecting with the extension declared negotiates the capability,
discovers the tool linkage via `_meta.ui.resourceUri`, and reads the `ui://` template
as `text/html;profile=mcp-app` content.

## Breaking Changes

None. Purely additive helper module; no existing code paths change.
2026-07-07 01:16:02 +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
afb968c468 Bound Stateful Session Retention to Prevent an Initialize-Flood DoS
## Motivation and Context

In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored
a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker
could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until
memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client.
The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count,
so the insecure behavior was the default.

Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python,
and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults
in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below):

- `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default
  and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode
  retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from
  an explicit `nil`.
- A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under
  the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions
  the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired),
  and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects:
  established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions
  while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS,
  which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode.
  The value is validated as a positive Integer or `nil`.

## How Has This Been Tested?

New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and
the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep
no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session
is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out,
and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to
the new secure default.

## Breaking Changes

Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions
lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require
the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
2026-07-05 19:39:56 +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
c145876bb9 Add server/discover and stateless lifecycle error codes per SEP-2575
## Motivation and Context

SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release)
makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of
per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both
the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and
the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration
and are purely additive:

- The `server/discover` method, which servers MUST implement: sessionless capability discovery
  returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`.
  The new `Server#discover` handler reuses the same data `init` already builds, but is state-free
  and idempotent: it stores no client info, never marks a session initialized, and responds regardless
  of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`).
  `serverInfo` is returned unfiltered because discovery precedes version negotiation;
  the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work.
  On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement
  and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with
  a bare POST in both stateful and stateless modes.
- The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003`
  and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet
  (that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of
  `URLElicitationRequiredError` is intentionally left untouched.

The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is
deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25,
where those methods are required.

Part of #389.

## How Has This Been Tested?

- `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities,
  server info, and instructions; it responds before `initialize` and with empty capabilities;
  it does not mark the session initialized (a subsequent `initialize` still succeeds);
  `instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")`
  now raises `MethodAlreadyDefinedError`.
- `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID
  returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`.
- New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values.

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None at the wire level for existing clients. One narrow API consequence: servers that previously registered
their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because
the method is built in.
2026-07-04 20:24:37 +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
4401833333 Recognize multi round-trip input_required results per SEP-2322
## Motivation and Context

SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 spec release)
introduces Multi Round-Trip Requests: instead of issuing in-flight server-to-client JSON-RPC requests,
a server answers with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
(of `sampling/createMessage`, `roots/list`, and `elicitation/create` request shapes) and
an opaque `requestState`; the client fulfills the requests and re-issues the original request with
`inputResponses` and the echoed `requestState`.

The wire contract (the `resultType` discriminator and the `inputRequests`/`requestState` shape)
stayed stable across all three closed TypeScript prototype iterations (typescript-sdk#2062/#2065,
the v2-stateless stack, and #2251) and the Python draft (python-sdk#2322), but the server-side
suspend/resume mechanism is still unsettled in both SDKs (typescript-sdk#2251 was put on hold on 2026-06-08).
This change therefore implements only the stable, additive vocabulary and the client-side recognition,
leaving server emission and automatic resumption for a follow-up once the reference design lands:

- New `MCP::ResultType` module with `COMPLETE` and `INPUT_REQUIRED` constants documenting the `resultType` values.
- `MCP::Client` raises the new `MCP::Client::InputRequiredError` (exposing `input_requests`, `request_state`,
  and the raw `result`) when any response carries `resultType: "input_required"`, instead of silently returning
  a non-final result as if it were the answer. The check lives in the shared request path,
  so every client method is covered. Servers on stable protocol versions never emit `resultType`,
  so default behavior is unchanged.

Part of #382.

## How Has This Been Tested?

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

- `call_tool` raises `InputRequiredError` for an `input_required` result and exposes `input_requests`,
  `request_state`, and the full raw result
- `call_tool` returns normally when `resultType` is `"complete"` and when it is absent
  (wire-compat regression for stable-protocol servers)
- `list_tools` also raises for `input_required` results, proving the recognition covers the shared request path

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None for spec-compliant stable servers, which never send `resultType`. A response that does carry
`resultType: "input_required"` now raises `MCP::Client::InputRequiredError` instead of being returned as
a final result, which was always a misinterpretation of the draft semantics.
2026-06-30 11:34:17 +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
ba543083a7 Validate Host and Origin Headers to Prevent DNS Rebinding per MCP 2025-11-25
## Motivation and Context

MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate
the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost,
to prevent DNS rebinding attacks against locally bound MCP servers.
`StreamableHTTPTransport` previously validated neither `Host` nor `Origin`,
so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin,
invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m.

The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of
external middleware) and the Python SDK (`transport_security.py` disables protection by default)
both ship this off for backwards compatibility, a constraint they cannot escape after 1.0.
Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline:

- `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`,
  compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`.
  This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`.
  A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one.
- `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port)
  case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches
  `Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected.
- `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers.

A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:

- a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed
- a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values
  (`127.0.0.1`, `localhost`, `[::1]`) are allowed
- an upper-cased loopback `Host` is accepted (case-insensitive matching)
- a non-loopback `Host` listed in `allowed_hosts:` is allowed
- a request with no `Host` header, and one with no `Origin` header, are allowed
- a default-port `Origin` (`http://localhost`) matches `Host: localhost:80`
- opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected
- a `host:port` `allowed_hosts:` entry matches that port only
- `Host` validation also applies to GET requests
- `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through

## Breaking Changes

`StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default.
A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`,
or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients
are unaffected.

Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
2026-06-28 12:06:13 +09:00