Commit graph ruby-sdk/test
Author SHA1 Message Date
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
Koichi ITO
4ed461c72f
Merge pull request #480 from koic/discover_finalize_and_client
Finalize `server/discover` and Add Client Modern Lifecycle Support per SEP-2575
2026-08-08 15:04:54 +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
Joshua J
f379e13b47
fix: stop leaking exception messages to clients via JSON-RPC error data
Tool handler exceptions and anything reaching JsonRpcHandler's blind
rescue had their #message echoed into the error data member, exposing
internals to untrusted clients (CWE-209). Capability errors keep their
client-facing message by re-raising through RequestHandlerError, the
one channel whose message is deliberately surfaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:28:17 +02:00
Koichi ITO
d51d4a0f91
Merge pull request #479 from koic/http_modern_path
Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575
2026-08-04 00:26:12 +09:00
Koichi ITO
58f6a1bda0
Merge pull request #478 from koic/stdio_era_lock
Serve both lifecycle eras over stdio with an era lock per SEP-2575
2026-08-04 00:25:52 +09:00
Ates Goral
6a43d3d6fb feat: deprecate roots sampling and logging
Warn when Roots, Sampling, or Logging APIs are used with MCP protocol version 2026-07-28 or newer.

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

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

Co-authored-by: Qlaw <noreply@qlaw.quick.shopify.io>
2026-08-02 13:13:10 -04:00
Koichi ITO
e591cbb804 Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575
## Motivation and Context

Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first,
like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside
every supported list enters a new sessionless modern path, while requests without the header (or with
a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path
means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }`
instead of a generic invalid-request error.

Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model,
and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates
by request shape: an `Mcp-Session-Id` binds the request to an established legacy session
(POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST
whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over
the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic
(`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request,
since Rack 3 inputs need not be rewindable.

The modern path (`handle_modern`) is a single POST/JSON exchange:

- GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405.
- It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`,
  and rejects requests carrying one with HTTP 400.
- Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path.
- Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against
  the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent.
  `Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII.
- HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400,
  `-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else
  including internal errors stays 200.
- Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`,
  whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of
  broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`.

The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple,
which would previously fall through the legacy path via the header default. Session-bound under
a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because
the session already negotiated the legacy lifecycle (mirroring the stdio era lock).
With the header missing or naming a stable-only version, it violates the header/body match
requirement and is rejected as a header mismatch (`-32020`).

Intentional behavior changes for previously-erroneous requests, all following from header-primary routing:
an unknown header version now yields `-32022` with data instead of the legacy `-32600` message
(`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version,
or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed
on the legacy path. Existing tests were updated to codify these.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list
for unknown header versions, `-32020` for header/body version, `Mcp-Method`,
and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection,
405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`),
`-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without
an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.

Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy
and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path,
a session-bound POST carrying the modern envelope is rejected as `-32600`,
and an oversized modern POST returns 413.

## Breaking Changes

None for conforming clients: requests without the header or with a stable-only version
are byte-identical to before, and legacy clients negotiating 2026-07-28 over
the classic handshake connect unchanged. Requests that were already rejected change error shape
(see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
2026-08-03 01:57:10 +09:00
Koichi ITO
09545f1272 Serve both lifecycle eras over stdio with an era lock per SEP-2575
## Motivation and Context

Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.

- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
  and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
  keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
  A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
  Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
  so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
  a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
  the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
  a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
  server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
  The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.

`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.

## Breaking Changes

None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
2026-08-03 01:54:06 +09:00
Koichi ITO
f4d939d349
Merge pull request #475 from koic/server_modern_core
Handle the SEP-2575 Modern Request Envelope in the Server Core
2026-08-03 00:29:31 +09:00
Koichi ITO
dc25610162 Support 2026-07-28 as the Latest Protocol Version
## Motivation and Context

Clients that require MCP 2026-07-28, such as ChatGPT, reject connections because the SDK
does not advertise that version: `Server#init` answers a 2026-07-28 request with 2025-11-25,
the Streamable HTTP transport 400s an `MCP-Protocol-Version: 2026-07-28` header,
and `server/discover` omits the version from `supportedVersions`.

Making 2026-07-28 the latest entry in `SUPPORTED_STABLE_PROTOCOL_VERSIONS` fixes all three at once,
since they all read that list. The version now serves both lifecycles of the SEP-2575 dual-era model:
negotiable through the legacy `initialize` handshake, and simultaneously the version of
the per-request modern lifecycle.

Parts of the 2026-07-28 feature set remain unimplemented, such as the stateless modern lifecycle
over the transports, and are being built separately. This change deliberately limits itself to
the version bump, focusing first on eliminating the connection errors that 2026-07-28 clients hit.

Known follow-up: the in-progress modern HTTP path routes requests to the sessionless modern lifecycle
when the header version is absent from `SUPPORTED_STABLE_PROTOCOL_VERSIONS`, so that routing predicate
needs rework on top of this change.

Closes #474

## How Has This Been Tested?

New regression tests: `initialize` with `protocolVersion: "2026-07-28"` echoes it back (`server_test.rb`),
a follow-up POST with `MCP-Protocol-Version: 2026-07-28` passes header validation
(`streamable_http_transport_test.rb`), and `Configuration.new(protocol_version: "2026-07-28")` is accepted
(`configuration_test.rb`).

`bundle exec rake test` passes (1454 runs, 0 failures). The conformance server suite passes 30/30.
The client `initialize` scenario is added to the expected-failures baseline: the conformance framework pins
the client's requested version to 2025-11-25 and does not accept 2026-07-28 yet; the entry carries a TODO
to remove it when the framework catches up.

## Breaking Changes

Servers that do not pin `configuration.protocol_version` now answer version negotiation fallbacks
and default client requests with 2026-07-28 instead of 2025-11-25. 2025-11-25 remains fully supported
and negotiable; clients that request it are unaffected.
2026-07-31 12:55:21 +09:00
Koichi ITO
5b4729294c Handle the SEP-2575 Modern Request Envelope in the Server Core
## Motivation and Context

Second step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release, building on the `MCP::RequestEnvelope` foundations.

`Server#handle_request` now lifts the per-request `_meta` envelope before dispatch: a request
whose `_meta` carries the full required triple is validated (raising `-32022` with
`data: { supported:, requested: }` for unsupported versions) and its envelope is threaded into
`tools/call`, `prompts/get`, `completion/complete`, `resources/read`, subscribe/unsubscribe,
and custom method handlers via `MCP::ServerContext`. A partial triple keeps flowing through
the legacy path untouched, so existing `_meta` usage (`progressToken`, trace context) is unaffected.

`MCP::ServerContext` gains per-request readers and a capability guard:

- `modern?`, `protocol_version`, `client_info`, and `client_capabilities` read from the envelope first
  and fall back to session state stored by `initialize` on legacy requests. The envelope always wins
  because servers MUST NOT infer client state from prior requests; nothing is written to the session
  on the modern path.
- `require_client_capability!(*path)` raises `Server::MissingRequiredClientCapabilityError`
  (`-32021` with `data: { requiredCapabilities: }`) when the request did not declare the capability.
- `notify_log_message` honors the per-request `io.modelcontextprotocol/logLevel`: on modern requests without it,
  the server MUST NOT send `notifications/message`, and an insufficient level drops the message the same way.

`MCP::ServerSession` gains the connection-era lock of the dual-era serving model: `era` is `nil`
until the first era-distinctive message succeeds, a successful `initialize` locks `:legacy` as
a side effect of `mark_initialized!`, `lock_era!` refuses to flip an established era, and transports can construct
per-request sessions with `era: :modern`. On a modern-locked session, `initialize` is rejected with `-32022`
(the modern lifecycle has no handshake) and the envelope triple becomes required for every other request
except `server/discover`.

The existing plain `RuntimeError` raises in `ServerSession#list_roots` and friends are intentionally unchanged:
converting them to `RequestHandlerError` subclasses would break callers rescuing `RuntimeError`.
Typed `-32021` errors are scoped to the new envelope-based guard.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` cover the wire behavior through `Server#handle`: envelope data exposure
without session mutation, `-32022` for unsupported envelope versions, partial triples staying legacy,
the envelope requirement and `initialize` rejection on modern-locked sessions, the `server/discover` exemption,
`require_client_capability!` returning `-32021` with the `requiredCapabilities` data shape,
and the `ServerSession` era-lock transitions (including preset `era: :modern` and invalid values).

New tests in `test/mcp/server_context_test.rb` cover the per-request logLevel gate (absent, insufficient,
and sufficient levels), the envelope-first/session-fallback readers, and string-keyed capability matching
in `require_client_capability!`.

## Breaking Changes

None. All new keyword arguments default to `nil`, legacy requests take exactly the same code path as before,
and the era lock only constrains sequences that were previously impossible (modern-era traffic).

Fix a Ruby 2.7 ArgumentError When Raising UnsupportedProtocolVersionError

## Motivation and Context

On Ruby 2.7, a method that declares a keyword parameter splits a trailing
symbol-keyed Hash positional argument into keywords. `UnsupportedProtocolVersionError#initialize`
declared `supported:`, so passing the request Hash as the second positional argument raised
`ArgumentError: unknown keywords: :name, :arguments, :_meta` inside the error constructor,
and the intended `-32022` response surfaced as a `-32603` internal error on the Ruby 2.7 CI job.

No caller overrides `supported:`; drop the keyword parameter and read
`Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS` directly, matching the keyword-free
shape of `ResourceNotFoundError` and `MissingRequiredClientCapabilityError`.

## How Has This Been Tested?

- Reproduced the `-32603` responses on Ruby 2.7.8 and confirmed both now return `-32022`
- Added a regression test constructing the error with a symbol-keyed request Hash
- `bundle exec rake test` on Ruby 2.7.8: 1467 runs, 0 failures

## Breaking Changes

None. The `supported:` keyword argument was never passed by any caller.
2026-07-31 10:16:41 +09:00
Koichi ITO
75fffab8c8
Merge pull request #471 from koic/stateless_foundations
Add Stateless Lifecycle Foundations per SEP-2575
2026-07-31 00:09:44 +09:00
Koichi ITO
5a879b3678
Merge pull request #445 from rickreyhsig-wealthbox/client-tool-annotations
Expose server tool annotations on MCP::Client::Tool
2026-07-28 01:21:53 +09:00
rickreyhsig-wealthbox
3591acdfaa Expose server tool annotations on MCP::Client::Tool
Client#tools/#list_tools built Tool objects without an annotations
field even though the server includes annotations in every
tools/list response. Add the reader and pass the raw hash through,
mirroring how output_schema is already handled.
2026-07-27 09:54:54 -04:00
Koichi ITO
d3aec1c3e6 Add Stateless Lifecycle Foundations per SEP-2575
## Motivation and Context

SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release) replaces
the `initialize` handshake with a stateless "modern" lifecycle: every request carries its protocol version,
client info, and client capabilities in reserved `_meta` keys, and the server validates each request independently.
This change lays the shared vocabulary for that lifecycle without changing any behavior:

- `MCP::Configuration` gains `LATEST_MODERN_PROTOCOL_VERSION` ("2026-07-28"), `SUPPORTED_MODERN_PROTOCOL_VERSIONS`,
  and `Configuration.modern_protocol_version?`. Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS`
  so `initialize` can never negotiate them and `protocol_version=` keeps rejecting them.
- `MCP::ErrorCodes` gains `HEADER_MISMATCH` (-32020), completing the spec's stateless lifecycle error code block alongside
   the existing -32021/-32022 constants.
- New `MCP::RequestEnvelope` parses and validates the per-request `_meta` triple (`io.modelcontextprotocol/protocolVersion`,
  `clientInfo`, `clientCapabilities`, plus the optional deprecated `logLevel`). A request is classified as modern only
  when the full triple is present, matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`,
  so partial or legacy `_meta` entries keep flowing through the legacy path untouched.
- New typed errors `Server::UnsupportedProtocolVersionError` (-32022 with `data: { supported:, requested: }`) and
  `Server::MissingRequiredClientCapabilityError` (-32021 with `data: { requiredCapabilities: }`) reuse the established `RequestHandlerError`
  `error_code`/`error_data` plumbing (the -32042 URL elicitation precedent).

Nothing dispatches through `RequestEnvelope` yet; the server-side modern handling, transport routing, and client support build on
this in follow-up changes.

Refs #389.

## How Has This Been Tested?

New `test/mcp/request_envelope_test.rb` covers the modern-triple classification (full triple, partial triple, string keys,
legacy `progressToken`), parsed field access, the -32022 error data shape, and invalid-request errors for incomplete or mistyped triples.
`test/mcp/configuration_test.rb` asserts the new constants, the predicate, and that modern versions stay rejected by `protocol_version=`.
`test/mcp/server_test.rb` asserts both typed errors surface on the wire with their SEP-2575 codes and data through `Server#handle`.
`test/mcp/error_codes_test.rb` pins -32020.

## Breaking Changes

None. Constants and classes are additive; no request path changes behavior.
2026-07-26 20:03:57 +09:00
King Star
43a0ba6266
fix: preserve explicit tool response content 2026-07-23 03:11:48 +08:00
Koichi ITO
991926a10d Lowercase response header names in StreamableHTTPTransport
## Motivation and Context

The Rack 3 SPEC requires response header names to be lowercase.
https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section:

> Header keys must not contain uppercase ASCII characters (A-Z).

https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers

However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`.
Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response
from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance.
HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected;
only in-process consumers that read the raw Rack headers hash see the new keys.

Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names
a protocol error on HTTP/2, so proxies and h2-capable servers already require it.
The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`,
so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention,
not a wire requirement.

Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name
a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers,
which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as
an `Access-Control-Expose-Headers` value, which browsers match case-insensitively;
and README/docs prose follows the MCP specification's documentation convention.
Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys.

AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing
invites reintroducing capitalized names in future transport work.

## How Has This Been Tested?

Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses.
Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop,
and rake conformance.

## Breaking Changes

Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport,
with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are
unaffected because header names are case-insensitive on the wire.
2026-07-16 11:45:48 +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
5533a6df92
Merge pull request #454 from koic/cross_app_access
Support SEP-990 Cross-App Access via ID-JAG and the jwt-bearer Grant
2026-07-16 00:58:08 +09:00
Koichi ITO
ced5cd1a95 Bound client-side message buffering in the HTTP transport
## Motivation and Context

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

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

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

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

## How Has This Been Tested?

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

## Breaking Changes

Responses larger than 4 MiB are now rejected by default; pass a larger g`max_message_bytes:` to accept them.
2026-07-15 12:02:15 +09:00
Koichi ITO
84090a88eb
Merge pull request #456 from koic/handle_initialize_request_without_id
Handle an initialize request sent without an ID
2026-07-15 12:01:59 +09:00
Koichi ITO
5425c9c4ee
Merge pull request #455 from koic/reject_notifications_for_unknown_sessions
Reject notifications carrying an unknown or expired session
2026-07-15 12:01:42 +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
ab4075c8c7 Fix RuboCop offenses detected with rubocop-shopify 3.0.0
## Motivation and Context

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

This change fixes them as follows:

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

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

## How Has This Been Tested?

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

## Breaking Changes

None. Freezing these constants only affects code that mutates them in place, which has never been supported.
2026-07-15 01:46:51 +09:00
Koichi ITO
fc0c6b8f3f Handle an initialize request sent without an ID
## Motivation and Context

`StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`,
which matches on the method name alone. An `initialize` message sent without an id (framed as a notification)
therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized,
but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil.

Two things then went wrong:

- `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response,
  so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`)
  and the client gets a malformed response or a 500.
- The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized.
  The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions
  toward the `max_sessions` cap until the reaper reclaims them.

`handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session
and acks with 202, mirroring how `handle_regular_request` already handles a nil response.
A well-formed `initialize` request (with an id) is unaffected.

## How Has This Been Tested?

A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize`
with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map.
It fails against the previous code (a 200 with a nil body element and a retained session) and passes now.

## Breaking Changes

None. A conforming client sends `initialize` as a request with an id and is unaffected;
only the malformed id-less form changes, from a broken 200 to a 202 with no retained session.
2026-07-14 01:09:18 +09:00
Koichi ITO
6236ea1b34 Reject notifications carrying an unknown or expired session
## Motivation and Context

In `StreamableHTTPTransport#handle_post`, the response and regular-request branches reject
a message whose session no longer exists (the response branch via `session_exists?`,
the regular path via `validate_and_touch_session`), but the notification branch dispatched unconditionally.
A notification carrying a present-but-unknown (or expired-but-not-yet-reaped) `Mcp-Session-Id`
therefore passed the `missing_session_id` gate and the ownership gate (which returns true
when the session does not exist) and reached `dispatch_notification` with no session,
running against the shared `Server` instead of a live `ServerSession`. With the default no-op notification
handlers this had no visible effect, but a custom notification handler would then run without
a live session behind it.

`handle_post` now checks `session_active?` in the notification branch, mirroring the other two branches.
`session_active?` rejects an unknown or expired session (evicting the expired one) without extending its lifetime,
so a stale session ID yields `404 Session not found` and a live session is dispatched exactly as before.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` assert that
a notification with an unknown session ID is rejected with `404 Session not found`
and that a notification carrying a live session ID is still accepted with `202`.
The new rejection test fails against the previous code (it returned `202`) and passes now.

## Breaking Changes

None for a conforming client: a client that has completed `initialize` sends its notifications with
the live session ID and is unaffected. Only a notification bearing an unknown or expired session ID,
previously accepted with `202`, now receives `404 Session not found`.
2026-07-14 01:07:25 +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
562afdf71e
Merge pull request #432 from koic/client_credentials_jwt
Support `private_key_jwt` Client Authentication for the client_credentials Grant
2026-07-12 02:14:11 +09:00
Koichi ITO
3d442d0a65
Merge pull request #448 from koic/handle_non_object_json_rpc_messages
Handle non-object JSON-RPC messages without raising
2026-07-12 02:12:46 +09:00
Koichi ITO
8aae052efd
Merge pull request #431 from koic/iss_parameter_validation
Validate the RFC 9207 `iss` Authorization Response Parameter per SEP-2468
2026-07-12 02:11:52 +09:00
Koichi ITO
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
ac12f91525 Send required initialize params in the mutex probe test helper
## Motivation and Context

`bundle exec rake` fails on main with four errors in `streamable_http_transport_test.rb`:

```
NoMethodError: undefined method '[]=' for nil
  streamable_http_transport_test.rb:5119 (install_mutex_probe_stream)
```

This is a semantic conflict between two merged PRs. #451 made the server reject `initialize` requests
that lack the required `protocolVersion`, `capabilities`, and `clientInfo` params with -32602. #449,
merged after but developed before it, added the "writes outside the mutex" tests,
whose `initialize_test_session` helper sends an `initialize` request with no params.
That request is now rejected, no session is created, so `@sessions[session_id]` is nil and installing
the probe stream raises `NoMethodError`.

The fix adds `params: initialize_params` to the helper's request body, matching the pattern used
by every other `initialize` request in the file via `InitializeParamsTestHelper`.

## How Has This Been Tested?

`bundle exec rake` now passes: 1318 runs, 0 failures, 0 errors, and RuboCop reports no offenses.
The four previously failing tests are the regression coverage.

## Breaking Changes

None. Test-only change.
2026-07-12 01:53:12 +09:00
Koichi ITO
e81196e9de
Merge pull request #449 from koic/avoid_blocking_sse_writes_under_session_mutex
Perform SSE stream writes outside the session mutex
2026-07-12 01:43:45 +09:00
Koichi ITO
abd1070cfa Validate Required Params for initialize and resources/subscribe
## Motivation and Context

Fixes https://github.com/modelcontextprotocol/ruby-sdk/issues/450

The MCP schema requires `protocolVersion`, `capabilities`, and `clientInfo` on `initialize` params,
and `uri` on `resources/subscribe` and `resources/unsubscribe` params. The server accepted requests with
these fields absent: `initialize` silently negotiated the server's default protocol version,
and `resources/subscribe` returned an empty success result. Both now return `-32602` Invalid params,
matching the Python SDK, which rejects such requests during pydantic validation.
The `uri` check runs before dispatch so it also covers handlers registered via `resources_subscribe_handler`
and `resources_unsubscribe_handler`.

A `protocolVersion` value that is present but unsupported still negotiates the server's fallback version,
as the spec's lifecycle section requires.

## How Has This Been Tested?

Reproduced the issue's stdio repro steps: both malformed requests now return `-32602` instead of succeeding.

## Breaking Changes

Clients that send `initialize` without `protocolVersion`, `capabilities`, or `clientInfo`,
or `resources/subscribe` / `resources/unsubscribe` without `uri`, now receive `-32602` Invalid params
instead of a success response. Such requests violate the MCP schema, but any code relying on
the previous lenient behavior (for example, test harnesses sending bare `initialize` requests)
must now include the required fields.
2026-07-11 22:36:34 +09:00
Koichi ITO
5b935eb031 Perform SSE stream writes outside the session mutex
## Motivation and Context

`StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`.
The send paths wrote to an SSE stream while holding that lock: the targeted and
broadcast branches of `send_notification`, the server-to-client `send_request`,
and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream`
(a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`.

A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly,
the server-side `write` waits until the socket buffer drains.
Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking):
new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions
all wait behind one slow reader. The broadcast path holds the lock across every session,
so one slow stream also delays delivery to the sessions after it.

The cleanup paths already release the lock before touching a stream: they collect `streams_to_close`
and call `close` outside `synchronize`. This change applies the same discipline to the writes.

- Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers
  the pending response there), then releases the lock before writing.
- A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`,
  which mutates `@sessions` under `@mutex` and closes the affected streams outside it.
  A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session,
  matching the previous behavior.
- `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`;
  the return values (true/false for a session, the delivered count for a broadcast) are unchanged.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records,
via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification,
broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now.
The existing send and cleanup tests still pass.

## Breaking Changes

None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
2026-07-11 17:17:59 +09:00
Koichi ITO
8875990909 Handle non-object JSON-RPC messages without raising
## Motivation and Context

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

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

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

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

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

## How Has This Been Tested?

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

## Breaking Changes

None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
2026-07-11 17:06:37 +09:00
Koichi ITO
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
04bb739c68 Support client-side elicitation with SEP-1034 schema defaults
## Motivation and Context

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

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

This change adds three pieces:

### Server-to-client request dispatch

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

### A standalone GET SSE listening stream

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

### SEP-1034 defaults

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

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

## How Has This Been Tested?

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

## Breaking Changes

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

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

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

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

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

## How Has This Been Tested?

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

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

## Breaking Changes

None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
2026-07-07 22:03:41 +09:00