Commit graph ruby-sdk/lib
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
1fa8673a58 Release 1.1.0
This release primarily addresses https://github.com/modelcontextprotocol/ruby-sdk/issues/474.
Work on full support for the 2026-07-28 specification, including stateless mode, is ongoing
and will continue in future releases.
2026-08-01 11:29:49 +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
Koichi ITO
565f4de781
Merge pull request #469 from jstar0/fix/preserve-explicit-tool-content
Preserve explicit tool response content
2026-07-26 04:19:39 +09:00
Koichi ITO
1ddd4d5910 Release 1.0.0
This first stable release marks the public API as stable per SEP-1730:
breaking changes now ship only in major releases, with the narrow exceptions
documented in VERSIONING.md. The release adds ROADMAP.md and VERSIONING.md
and contains no behavior changes.

Future feature proposals can be incorporated without breaking changes.
No incompatibilities have been identified with the known issues and pull requests.
2026-07-23 13:10:51 +09:00
King Star
43a0ba6266
fix: preserve explicit tool response content 2026-07-23 03:11:48 +08:00
Koichi ITO
f9fd78e3fb
Merge pull request #462 from koic/release_0_25_0
Release 0.25.0
2026-07-18 14:04:54 +09:00
Koichi ITO
df578d79ca Release 0.25.0
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.
2026-07-18 10:57:14 +09: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
1d8db69df0 Release 0.24.0
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.

One of the requirements for the official SDK to move from Tier 3 to Tier 2 is reaching v1.0,
and that milestone is now quite close.

The main considerations for v1.0 are API stability and minimizing breaking changes.
Based on the work in my local branch, a path toward Tier 1 is already becoming clear from
my perspective, and this release is part of the work to prepare for Tier 2.
2026-07-12 02:32:53 +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
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