## Motivation and Context
`MCP::Tool::Schema` previously used the pure-Ruby `json-schema` gem for
both construction-time metaschema validation and runtime argument /
result validation. For deep schemas this cost ~32-100ms per
construction (see issue #364).
`json_schemer` (https://rubygems.org/gems/json_schemer, v2.5.0) is
an actively maintained alternative that is much faster on this workload.
Switching reduces cold-start warming pressure in horizontally scaled
multi-tenant deployments (the scenario raised in #364) and brings
runtime data validation down to sub-millisecond per call once
the per-instance schemer is memoized.
The implementation preserves the legacy behavior of the `json-schema`
based code. Metaschema is pinned to draft-04 (`meta_schema:` option);
the `$schema` dialect URI is still emitted in `to_h` as 2020-12
but runtime validation continues to use draft-04, exactly as before.
Moving the runtime validator itself to 2020-12 is out of scope for
this PR and warrants its own discussion. `format` keywords are not enforced
(`format: false` option), matching what `json-schema`'s draft-04 path did.
Malformed schemas (e.g. an invalid `pattern` regex) continue to
surface as `ArgumentError, "Invalid JSON Schema: ..."`;
the `RegexpError` `json_schemer` raises during construction is wrapped.
`Symbol` values in argument data are coerced to strings inside
the internal `stringify` helper so they validate against `type: "string"`
the same way they did before.
The public `Schema#schema` accessor is dropped. It was a refactor
artifact from #198 (the consolidation of `InputSchema`'s
`properties` / `required` readers into a single hash) with no callers
in `lib/`; tests that needed the merged hash now read it through
`to_h`. The accessor's only remaining role would have been to expose
a mutation path that could desynchronize the memoized `@schemer`,
which this change removes.
Runtime dependency delta: drops `json-schema` (and `addressable`),
adds `json_schemer`, `hana`, `regexp_parser`, and `simpleidn`. Each added
gem is a single-purpose library tied to JSON Schema spec compliance.
Closes#364.
## How Has This Been Tested?
Side-by-side benchmark on representative schemas (simple, with `$ref` /
`additionalProperties`, nested, depth 20, depth 40):
construction-time metaschema validation is 4.7x to ~100x faster;
runtime data validation with the memoized schemer is sub-millisecond
across all sizes.
Four existing tests had their stub targets or message assertions updated
to track the new validator while preserving the original test intent.
The "unexpected errors bubble up" tests in `input_schema_test.rb`
and `output_schema_test.rb` now stub `JSONSchemer::Schema#validate`
instead of `JSON::Validator.fully_validate`. The cache tests in
`schema_test.rb` now stub `JSONSchemer::Schema#validate_schema`.
The two "detailed error message" tests in `tool_test.rb` switched from
asserting `json-schema`'s exact wording to format-agnostic substrings
(`"properties/count/minimum"`, `"number"`). The "required arguments are
converted to strings" test in `input_schema_test.rb` now reads the result
through `to_h[:required]` instead of the removed `schema` accessor.
Regression tests added for the legacy-behavior preservations above:
`format` keyword is not enforced, invalid `pattern` raises `ArgumentError`
(not `RegexpError`), and `Symbol` values validate against `type: "string"`.
## Breaking Changes
`Schema#schema` is no longer a public method. The accessor had no callers
outside the test suite, but anyone reading the internal representation
directly (rather than through `to_h`) will need to switch.
`Schema` / `InputSchema` / `OutputSchema` otherwise keep
their constructor signatures, `to_h` output, `ValidationError` /
`ArgumentError` raise points, and the `$schema` dialect emission.
The validator error message wording inside `ArgumentError` and
`ValidationError` now comes from `json_schemer` and still includes
the JSON pointer path and a description of the mismatch.
## Motivation and Context
SEP-2207 describes how an OAuth client obtains refresh tokens: it
declares the `refresh_token` grant type in its client metadata and
requests the `offline_access` scope, but only when the authorization
server advertises `offline_access` in its metadata `scopes_supported`.
Requesting `offline_access` when the server does not list it is forbidden.
The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never
requested `offline_access`, so it could not obtain a refresh token from a server
that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs
and makes the `auth/offline-access-scope` conformance scenario pass
(previously it reported one warning, `sep-2207-client-metadata-grant-types`,
and was listed in the expected-failures baseline).
The change adds:
- `Flow#augment_scope_with_offline_access`, called from `run!` after
`resolve_scope`, appends `offline_access` to the requested scope when
both the client opted into refresh tokens (its registered
`grant_types` include `refresh_token`) and the authorization server
advertises `offline_access` in its metadata `scopes_supported`.
Already-present `offline_access` is not duplicated. Gating on the server
advertisement keeps the SDK from ever requesting the scope where it is not supported.
- `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection
of the SDK's refresh support, which clears the SEP-2207 grant-types warning.
- `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
New `Flow` tests cover: `offline_access` is requested when the server advertises it
and the client declared the `refresh_token` grant; `offline_access` is NOT requested
when the client did not declare the grant; `offline_access` is NOT requested
when the server does not advertise it (even with the grant declared);
and `offline_access` is not duplicated when it is already part of the resolved scope.
Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings,
and `auth/offline-access-not-supported` still passes 13/13 (no regression).
`bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance`
are all green.
## Breaking Changes
None. `offline_access` is appended only when the client's own `grant_types` opt
into refresh tokens and the authorization server advertises the scope, so existing clients
that do neither see no change in the scope they request.
## Motivation and Context
`MCP::Tool::Schema#initialize` validated every instance against the draft-04 metaschema
via `JSON::Validator.fully_validate`, costing ~32ms for deep schemas (up to ~100ms).
When schemas are built dynamically and repeatedly, this work is wasted because
the result depends only on the schema content.
This caches successful validations by a content digest, so an identical schema is validated
once and subsequent constructions skip the traversal (~100ms to ~0.1ms on a cache hit).
The cache is bounded and thread-safe. It supersedes the `validate: false` escape hatch proposed
in #362, which would have added permanent public interface and allowed invalid schemas
to reach clients.
Python and TypeScript SDKs do not metaschema-validate schema definitions, so this cost is specific
to the Ruby SDK; caching reduces it without weakening the default or changing validation semantics.
## How Has This Been Tested?
Added regression tests in `test/mcp/tool/schema_test.rb`: identical schemas validate only once,
distinct schemas validate separately, a cache hit still yields a usable and correctly validated schema,
invalid schemas raise on every construction and are not cached, a schema at the normalization depth limit
is cached without a nesting error, and `ValidationCache` evicts the oldest entry beyond its max size.
The full suite (`rake test`) and `rake rubocop` pass.
## Breaking Changes
None. Validation safety is preserved: every distinct schema is still validated, and invalid schemas
continue to raise. The initializer signature and validation semantics are unchanged.
## Motivation and Context
The MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00` define
Client ID Metadata Documents (CIMD): a client publishes its OAuth metadata at an HTTPS URL and uses that URL
as the OAuth `client_id` instead of going through Dynamic Client Registration. The Python and TypeScript SDKs
already implement this; the Ruby SDK now reaches parity.
The change adds:
- `MCP::Client::OAuth::Provider` accepts an optional `client_id_metadata_document_url:` keyword.
- A new `Discovery.client_id_metadata_document_url?` validator enforces structural requirements.
Spec-required: `https` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments
(including the percent-encoded form `%2E`). Stricter than the draft as an SDK policy: query strings
are also rejected, because different encodings of the same query would yield distinct `client_id` strings for
the same logical document. The loopback `http` carve-out used for discovery URLs does not extend to CIMD URLs,
since the value is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback.
Validation failures raise `Provider::InvalidClientIDMetadataDocumentURLError`.
- `Flow#ensure_client_registered` routes through CIMD only when the authorization server advertises
`client_id_metadata_document_supported` as JSON `boolean true`; truthy non-boolean values
(string `"true"`, `"false"`, etc.) fall back to DCR, matching the gating in the Python and TypeScript SDKs.
- The CIMD `client_id` is NOT persisted to storage. AS support is re-read on every flow so a server
that later drops CIMD support stops receiving the stale URL. Pre-registered `client_information`
continues to win over CIMD when both are available.
- `Flow#refresh!` reconstructs the CIMD `client_id` from live AS metadata when refreshing tokens obtained via CIMD.
When support has been withdrawn and no other identity is stored, it raises a clear `AuthorizationError` rather than
sending a `client_id` the authorization server no longer recognises.
- The Provider docstring and README OAuth section explain that DCR `client_metadata` and the JSON document served at
the CIMD URL are separate artifacts: DCR metadata MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `auth/basic-cimd` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
`Discovery` tests cover: https with path accepted; non-https rejected (http, ftp, file); root and empty path rejected;
fragment, query, and userinfo rejected; dot segments rejected including the percent-encoded form `%2E`; nil, empty,
and malformed inputs rejected.
`Provider` tests cover: the default `nil`; rejection of http URLs (loopback included); non-https schemes;
root and empty path; fragment; query; userinfo; and dot segments.
`Flow#run!` tests cover: skip DCR when the authorization server advertises CIMD and the provider has a URL;
fall back to DCR when the authorization server does not advertise support; fall back to DCR when the value is
the string `"false"` or `"true"`; pre-registered `client_information` wins over CIMD; the CIMD `client_id` is not
persisted to storage; a second flow against an authorization server that has dropped CIMD support recovers via DCR.
`Flow#refresh!` tests cover: refresh reconstructs the CIMD `client_id` without a stored `client_information`;
refresh raises when the authorization server no longer advertises CIMD support; stored `client_information`
takes precedence over the CIMD URL during refresh.
Conformance: `auth/basic-cimd` now passes 12/12 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`,
and `bundle exec rake conformance` are all green.
## Breaking Changes
None. The `client_id_metadata_document_url:` keyword is purely opt-in and defaults to `nil`.
When the keyword is omitted, the DCR flow is unchanged. Existing OAuth users see no behaviour change.
The CIMD path is only entered when both the user explicitly configures a URL and the authorization server
advertises support via `client_id_metadata_document_supported: true`.
## Motivation and Context
RFC 9110 defines media type comparison as case-insensitive,
but `parse_accept_header` compared types without normalizing case.
This caused `Application/JSON` or `TEXT/Event-Stream` to be rejected
with 406 Not Acceptable.
## How Has This Been Tested?
Added tests for mixed-case Accept headers:
- `Accept: Application/JSON, Text/Event-Stream` succeeds for POST
- `Accept: TEXT/EVENT-STREAM` succeeds for GET SSE
## Breaking Changes
None. This is a bug fix bringing behavior into conformance with RFC 9110.
## Motivation and Context
The MCP specification defines `ping` as a bidirectional utility: either party may issue it.
https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
The previous PR added `MCP::Client#ping`. This PR adds the symmetric server-side send capability,
`ServerSession#ping`.
This aligns the Ruby SDK with the Python SDK (`ServerSession.send_ping`) and
the TypeScript SDK (`Server.ping()`), both of which expose a server-initiated ping.
In practice this enables a Ruby MCP server to health-check non-Ruby clients
(e.g., Claude Desktop, the Python or TypeScript SDK client) during long-running tool operations.
Scope is intentionally server-only: this PR does not add client-side inbound request dispatch,
which is a larger refactor outside the ping utility. A Ruby `MCP::Client` therefore still cannot receive
a server-initiated ping; that gap is tracked as a separate follow-up.
## How Has This Been Tested?
New file `test/mcp/server_session_ping_test.rb` covers:
- success (empty `result` returned)
- `related_request_id` propagated through the transport when a session ID is set
- `ValidationError` when the transport returns `nil`
- `ValidationError` when the transport returns a non-Hash `result`
- transport-level errors propagate unchanged
- ping succeeds without any client capability declaration (per spec, ping has no capability gate)
`test/mcp/server_context_test.rb` adds two tests for `ServerContext#ping` delegation
(happy path and `NoMethodError` when the session lacks `#ping`).
The existing server-side `ping` handler tests continue to pass unchanged.
## Breaking Changes
None. `MCP::ServerContext#ping` and `MCP::Server::ValidationError` are purely additive.
## Motivation and Context
`handle_post` and `handle_get` already use this helper, but
`handle_delete` inlined `request.env["HTTP_MCP_SESSION_ID"]`.
This aligns all three handlers consistently.
## How Has This Been Tested?
It has passed existing tests.
## Breaking Changes
None.
## Motivation and Context
The Ruby SDK accepted duplicate `initialize` requests after a session was already initialized.
On stdio, a second `initialize` silently overwrote the per-session `clientInfo` and `clientCapabilities`
(including with an older `protocolVersion`). On Streamable HTTP, every `initialize` minted a fresh
`Mcp-Session-Id` and `ServerSession`, abandoning the originally negotiated session.
MCP specification (`2025-06-18` / `2025-11-25` lifecycle) states that the initialization phase
MUST be the first interaction between client and server;
re-initialization on an established session is not part of the defined lifecycle.
TypeScript SDK rejects duplicate `initialize` on a live session with HTTP 400 + JSON-RPC `-32600`
("Invalid Request: Server already initialized"), and Python SDK does not mint a new session
on duplicate `initialize`. The Ruby SDK was the outlier; this change aligns it with the TypeScript SDK.
- `ServerSession` tracks an `@initialized` flag, exposed via `initialized?` and
set by `mark_initialized!` after a successful `initialize` response.
- `Server#init` raises `RequestHandlerError(error_type: :invalid_request)` when
the session is already initialized, which the existing error mapping converts
to JSON-RPC `-32600 Invalid Request`.
- `StreamableHTTPTransport#handle_post` short-circuits at the transport layer:
duplicate `initialize` against a live session returns HTTP 400 + JSON-RPC
`-32600`; a stale or expired `Mcp-Session-Id` returns 404 (evicting the expired entry
instead of misreporting it as a duplicate).
- `handle_initialization` evicts the registered session and omits the `Mcp-Session-Id`
header when the first `initialize` fails before `mark_initialized!` is reached,
so retries do not collide with an orphaned ID.
- Non-Hash JSON-RPC POST bodies (e.g. batched arrays, which are not supported in `2025-11-25`)
are explicitly rejected with HTTP 400 + JSON-RPC `-32600` rather than falling through to
an unparseable Rack response.
## How Has This Been Tested?
- Server tests: a second `initialize` on the same `ServerSession` returns
`code: -32600` and the original `clientInfo` is preserved.
- Streamable HTTP tests: duplicate `initialize` with a live `Mcp-Session-Id`
returns HTTP 400 + `-32600` and the original session remains usable for
subsequent `ping`; stale `Mcp-Session-Id` returns 404; an idle-expired
session is evicted on duplicate `initialize` and returns 404; a failed
`initialize` (invalid `jsonrpc` envelope) does not leak `Mcp-Session-Id` and
leaves `@sessions` empty; an array body is rejected with HTTP 400 + `-32600`.
- Stdio tests: a second `initialize` on the same stdio session returns
`code: -32600` and the original `clientInfo` is preserved.
## Breaking Changes
Clients that previously sent `initialize` more than once on the same session
now receive a JSON-RPC error with `code: -32600` for the second and later requests
instead of silently overwriting session state (stdio) or being re-issued
a new `Mcp-Session-Id` (Streamable HTTP). Clients that follow the MCP specification
(single `initialize` per session) are unaffected.
Additionally, non-Hash JSON-RPC POST bodies on Streamable HTTP now return
HTTP 400 + JSON-RPC `-32600` rather than falling through to a broken Rack response.
The previous behavior produced an unparseable response, so this is unlikely to
affect any working client.
Closes#349.
## Motivation and Context
MCP 2025-11-25 does not support JSON-RPC batch (batch support was removed in 2025-06-18),
so request bodies are expected to be a single JSON-RPC message object. The previous code path:
```ruby
body = parse_request_body(body_string)
return body unless body.is_a?(Hash)
```
returned the parsed JSON value as if it were a Rack response when
the body was a JSON array (former batch shape), a string, a number,
or any other non-Hash JSON value. This produced a malformed Rack response
instead of a proper HTTP 400.
This was a pre-existing bug exposed during follow-up review of #347
(`MCP-Protocol-Version` header validation), which surfaced it but did not fix
the broken response shape for non-Hash bodies sent with a valid header.
### Behavior
- `parse_request_body` now raises a private `InvalidJsonError` on parse
failure instead of returning a Rack response tuple. The overloaded
return type and the `parse_error_tuple?` discriminator are gone.
- `handle_post` rescues `InvalidJsonError` and returns the same plain
JSON 400 response as before (`{"error":"Invalid JSON"}`), so parse
error handling is observably unchanged.
- Non-Hash parsed bodies (arrays, strings, numbers, booleans, null) now
return HTTP 400 with a plain JSON body explaining that the body must
be a single JSON-RPC message object, instead of producing a malformed
Rack response.
The error response shape stays in the existing plain JSON style for
consistency with `validate_content_type`, `not_acceptable_response`,
and the other transport-level error helpers. Unifying all of these to
a JSON-RPC error envelope (matching the Python and TypeScript SDKs) is
deferred to a separate follow-up.
### SDK Comparison
- Python SDK (`src/mcp/server/streamable_http.py`): Already rejects
non-Hash bodies (Array, primitive) with HTTP 400. Uses a JSON-RPC
error envelope with `INVALID_PARAMS`.
- TypeScript SDK (`packages/server/src/server/streamableHttp.ts`):
As of `main`, still processes JSON arrays as JSON-RPC batches and
has not yet caught up with the 2025-06-18 batch removal. Primitives
fail schema validation and return 400 with a JSON-RPC error envelope.
This change brings the Ruby SDK in line with the current MCP spec
(no batch). The Ruby SDK is now closer to the spec than the TypeScript
SDK's `main` branch on this specific point.
## How Has This Been Tested?
Existing regression test `test "handles POST request with invalid
JSON"` continues to pass unchanged (plain JSON response preserved).
Added new regression tests:
- POST with a JSON array body returns 400 with a clear error message
- POST with a non-object JSON body (e.g. `"foo"`) returns 400
`bundle exec rake test` and `bundle exec rake rubocop` both pass.
## Breaking Changes
None for compliant clients sending single JSON-RPC message objects.
Clients that were sending JSON arrays (batch requests, no longer
supported as of MCP 2025-11-25) or other non-object bodies will now
receive a proper HTTP 400 with a JSON error body instead of
the previous malformed Rack response; no client could have relied on
the broken pre-existing behavior.
## Motivation and Context
The MCP Authorization spec (2025-11-25) requires clients to support
Protected Resource Metadata discovery (RFC 9728), Authorization
Server Metadata discovery (RFC 8414), Dynamic Client Registration,
and OAuth 2.1 Authorization Code grant with PKCE (S256). The Ruby SDK
previously had no client-side OAuth support: the HTTP transport raised
`RequestHandlerError` on a 401 with no recovery, and 21 `auth/*` scenarios
were listed as known failures in `conformance/expected_failures.yml`.
This aligns the Ruby SDK with the Python and TypeScript SDKs,
both of which expose a pluggable OAuth client provider that drives
the full discovery + DCR + authorization + token exchange flow.
`MCP::Client::OAuth::Provider` mirrors that surface: callers supply
`client_metadata`, a `redirect_uri`, and pluggable `redirect_handler`
/ `callback_handler` hooks plus a token/client information storage.
`MCP::Client::OAuth::InMemoryStorage` is provided as a default.
`MCP::Client::HTTP` accepts a new optional `oauth:` keyword. When present,
the transport attaches `Authorization: Bearer` to every request,
and on a 401 parses `WWW-Authenticate` for the `resource_metadata` URL
and `scope` challenge, drives the discovery + DCR + authorization code flow
through the provider, and retries the failed request once with the acquired token.
Spec compliance details:
- The `WWW-Authenticate` parser handles multiple challenges
(e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`), extracting only
the parameters that belong to the Bearer challenge.
- The PRM `resource` value is required to cover the MCP server URL
(same scheme/host/port, with the PRM path as a prefix of the server path)
before the flow continues, preventing redirection of issued tokens to a different audience.
- Both authorization and token requests always include a `resource`
parameter (RFC 8707), defaulting to a canonicalized form of
the MCP server URL when PRM does not advertise one.
- `client_secret_basic`, `client_secret_post`, and `none` token endpoint
authentication methods are supported. When pre-registered client information
specifies no `token_endpoint_auth_method`, the transport defaults to
`client_secret_basic` per RFC 6749 §2.3.1 an dthe TS/Python SDK convention.
Pre-registered client credentials supplied via `Storage#save_client_information`
with either string or symbol keys are honored, skipping DCR.
- Scope resolution follows the spec order: the `WWW-Authenticate` challenge scope wins first,
then PRM `scopes_supported`, and `Provider#scope` is the last-resort fallback only
if neither is supplied (the provider-supplied scope must not pre-empt server-advertised scope).
- Token endpoint error responses (RFC 6749 §5.2) are parsed: a 4x with
`error: "invalid_grant"` raises `Flow::InvalidGrantError`,
so `MCP::Client::HTTP` can discard the stored refresh token only when
the AS truly says it is dead. Generic `AuthorizationError`
(5xx, network failures, other error codes) leaves the refresh token
intact, so a transient AS outage does not force interactive reauth
on the next attempt.
- The OAuth `state` parameter is compared via
`OpenSSL.fixed_length_secure_compare` (with a `bytesize` guard) to
prevent timing-based discovery of the expected value.
- The 401 retry guard is a request-scoped local variable, so an MCP server
that keeps returning 401 is surfaced as an error after one
retry rather than triggering an infinite re-authorization loop.
## How Has This Been Tested?
Unit tests cover PKCE generation, multi-challenge `WWW-Authenticate` parsing,
PRM and Authorization Server metadata URL builders, the full Flow orchestrator
(success path, state mismatch, missing PKCE support, skipping DCR for both string
and symbol-keyed preregistered client information, `client_secret_basic`
token endpoint authentication, PRM `resource` mismatch rejection, fallback to
the server URL when PRM omits `resource`, scope resolution preferring PRM
`scopes_supported` over `Provider#scope`, default `client_secret_basic` for
confidential clients without an explicit `token_endpoint_auth_method`,
and the new `InvalidGrantError` vs generic `AuthorizationError` split for
token endpoint rejections), and the HTTP transport behavior
(401 recovery + retry, no-provider fallthrough, no infinite loop on repeated 401,
no infinite loop on flow failure, refresh-token retention on transient 5xx from
the token endpoint).
Conformance: `bundle exec rake conformance` newly passes 16 `auth/*`
scenarios beyond the existing baseline, including
`auth/metadata-default`, `metadata-var{1,2,3}`,
`scope-from-www-authenticate`, `scope-from-scopes-supported`,
`scope-omitted-when-undefined`, `scope-retry-limit`,
`token-endpoint-auth-{basic,post,none}`, `pre-registration`,
`resource-mismatch`, and `offline-access-not-supported`. Remaining
`auth/*` scenarios stay in `expected_failures.yml`.
`bundle exec rake test rubocop` is clean.
## Breaking Changes
None. `MCP::Client::HTTP#initialize` accepts a new optional `oauth:`
keyword that defaults to `nil`, preserving existing behavior. When unset,
the transport raises `MCP::Client::RequestHandlerError` on a 401 exactly as before.
## Motivation and Context
When an `initialize` POST carries an `MCP-Protocol-Version` HTTP header that disagrees with
`params.protocolVersion` in the JSON-RPC body, the Streamable HTTP server accepts the request
and negotiates using the body value. The behavior was already encoded in `handle_post`
(the header check is skipped on `initialize`) but it was not stated in the code and there was
no regression test for the specific mismatch case.
MCP 2025-06-18 / 2025-11-25 only requires `MCP-Protocol-Version` on requests subsequent to
`initialize`, and the spec does not require the header and body to agree on the initial `initialize`.
All official MCP SDKs (TypeScript, Python, Rust, Java, Go, PHP) accept the mismatch and
treat the JSON-RPC body as authoritative.
## How Has This Been Tested?
Streamable HTTP tests: two new regression tests confirm that on an `initialize` POST
with a supported but mismatched `MCP-Protocol-Version` header, the server returns
HTTP 200 and the response's `result.protocolVersion` matches the JSON-RPC body value,
in both mismatch directions (header older / body newer, and header newer / body older).
## Breaking Changes
None. The behavior is unchanged: `initialize` requests with a mismatched `MCP-Protocol-Version` header
are still accepted, and the JSON-RPC body `params.protocolVersion` continues to drive negotiation.
This commit only adds an explanatory comment and regression tests.
Closes#351.
## Motivation and Context
MCP 2025-11-25 (basic/transports#protocol-version-header) requires servers
to respond with `400 Bad Request` when receiving a request with an invalid
or unsupported `MCP-Protocol-Version` header:
> If the server receives a request with an invalid or unsupported
> `MCP-Protocol-Version`, it MUST respond with `400 Bad Request`.
The Ruby SDK was not reading the header at all, accepting any value
(including malformed strings like `not-a-version` and unsupported versions
like `1900-01-01`) and dispatching requests normally with HTTP 200.
## Behavior
`StreamableHTTPTransport` now validates the `MCP-Protocol-Version` header
on POST (non-initialize), GET, and DELETE requests. Missing headers are
accepted as before; the spec SHOULD requirement to default to `2025-03-26`
is intentionally left for a follow-up. The `initialize` POST is exempt
because the client does not know the negotiated version until the response
arrives, matching the Python (`src/mcp/server/streamable_http.py`) and
TypeScript (`packages/server/src/server/streamableHttp.ts`) SDKs.
The error response uses a JSON-RPC envelope with `code: -32600`
(`INVALID_REQUEST`) to match the Python SDK shape:
```json
{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": "Bad Request: Unsupported protocol version: <value>. Supported versions: ..."
}
}
```
Validation order is session then protocol version, matching the Python and
TypeScript SDKs.
`handle_post` was reorganized so that non-Hash request bodies (e.g., JSON
arrays, which MCP 2025-11-25 no longer supports as batches) also pass
through the header check. The pre-existing broken response for Array
bodies sent with a valid header is unchanged and is tracked as a separate
follow-up.
## How Has This Been Tested?
Added regression tests covering:
- POST `initialize` ignores the header (bypass)
- POST non-initialize with unsupported / malformed / valid / missing values
- POST array body with unsupported value returns 400
- GET with unsupported / missing values
- DELETE with unsupported value in both stateful and stateless modes
- DELETE validates session before protocol version
`bundle exec rake test` and `bundle exec rake rubocop` both pass.
## Breaking Changes
Strictly additive for compliant clients. Third-party clients sending stale
or malformed `MCP-Protocol-Version` values will now receive `400` instead
of `200`, which is the intended spec behavior.
Fixes#346.
## Motivation and Context
MCP 2025-11-25 (SEP-1613) establishes JSON Schema 2020-12 as the default dialect
for MCP schema definitions.
https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1613
The Ruby SDK was emitting tool input and output schemas with no `$schema` field,
leaving the dialect implicit and inconsistent with the TypeScript SDK
(`packages/core/src/util/standardSchema.ts`, which emits with `target: 'draft-2020-12'`)
and the Python SDK (which preserves `$schema` in its types and conformance fixtures).
`Tool::Schema#to_h` now includes `"$schema": "https://json-schema.org/draft/2020-12/schema"`
unless the user supplied a different `$schema` value, so clients see the canonical dialect URI
on the wire.
Runtime metaschema and argument validation remain on draft-04 because the `json-schema` gem
does not yet support 2020-12. Switching the runtime validator is left as a separate change
because it requires either replacing the gem or waiting for upstream 2020-12 support.
## How Has This Been Tested?
Updated `Tool::Schema#to_h` expectations across `input_schema_test`, `output_schema_test`,
`tool_test`, and the `tools/list` server tests to reflect the new `$schema` field.
Added focused regression tests:
- `to_h` preserves a user-supplied `$schema` URI rather than overwriting it
(covered for both symbol-keyed input and the string-keyed Hash form that
goes through the initializer's `symbolize_names` normalization)
- `validate_arguments` / `validate_result` continue to work when the user supplies
a 2020-12 `$schema` URI on the schema (the validator strips the unknown top-level
dialect URI before invoking the draft-04 validator, instead of raising `JSON::Schema::SchemaError`)
- `tools/list` JSON-RPC response includes the 2020-12 `$schema` on both `inputSchema`
and `outputSchema` (transport-boundary assertion)
`bundle exec rake test` and `bundle exec rake rubocop` both pass.
## Breaking Changes
Additive change. Tolerant consumers of the `tools/list` payload are unaffected.
Consumers that strictly assert hash equality on the emitted `inputSchema` / `outputSchema`
need to account for the new `$schema` key, but no schema semantics change.
Schemas using 2020-12-only keywords (e.g. `prefixItems`, nested `$schema` on subschemas)
are still rejected by the draft-04 metaschema validator, as before.
Lifting that restriction depends on adopting a 2020-12-capable validator and is out of scope
for this change.
## Motivation and Context
PR #248 introduced server conformance testing using `@modelcontextprotocol/conformance`.
This extends coverage to the client side so that `MCP::Client` behavior can also be validated against
the MCP specification.
### Scope
Client conformance is driven by the conformance runner, which spawns test servers for each scenario
and invokes `conformance/client.rb` against them. The client script delegates the MCP initialize
handshake to `MCP::Client#connect` and executes scenario-specific operations (for example listing
tools or calling a tool).
### Usage
Run all conformance tests (server + client):
```console
bundle exec rake conformance
```
Run a single client scenario:
```console
bundle exec rake conformance SCENARIO=initialize
```
List available scenarios:
```console
bundle exec rake conformance_list
```
## How Has This Been Tested?
Ran `bundle exec rake conformance` locally.
- Server conformance: 40 passed, 0 failed.
- Client conformance: 3 passed, 54 failed (all 54 listed in `expected_failures.yml`).
## Breaking Change
No. This change only adds test infrastructure under `conformance/` and does not modify SDK behavior.
New features and changes have landed, making it a good time to cut a release.
Further feature proposals can be incorporated in future releases.
It would be best to make the usage from #336 available sooner rather than later.
## Motivation and Context
This commit implements Phase 1 of 3 for #334.
The MCP specification requires an `initialize` request followed by
a `notifications/initialized` notification before any other interaction.
PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect`
to expose this handshake explicitly for the HTTP transport,
but stdio remained on its private `initialize_session` lazy-init path,
inconsistent with the Python SDK (`ClientSession.initialize()`) and
TypeScript SDK (`Client.connect(transport)`),
both of which require an explicit handshake call regardless of transport.
The implicit init path on the first `send_request` is
preserved as a non-breaking compatibility shim. Public API added on `Stdio`:
- `connect(client_info:, protocol_version:, capabilities:)` performs the
handshake, validates the negotiated `protocolVersion` against
`MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`,
caches the `InitializeResult`, and is idempotent.
- `connected?` reports handshake completion.
- `server_info` exposes the cached `InitializeResult`.
Documentation and examples are updated so new users learn the explicit pattern
from the start.
## How Has This Been Tested?
Fifteen regression tests cover: explicit handshake, `server_info` caching,
idempotence, custom keyword arguments, JSON-RPC error response, missing
or non-Hash `result`, unsupported protocol version with state rollback,
state rollback when the `notifications/initialized` write fails,
retry after a failed handshake, and the `connected?` / `server_info` lifecycle.
The pre-existing stdio tests pass without modification, verifying that
the implicit-init compatibility path still works.
## Breaking Changes
None for the typical "implicit init via `client.tools` / `client.call_tool`" usage.
Existing stdio clients keep working unchanged.
Three observable behavior changes for code that inspects transport state directly:
- `Client#connected?` for a never-initialized stdio client returns `false`
(previously fell through to a `true` default via the `respond_to?` fallback).
- `Client#server_info` for stdio returns the cached `InitializeResult`
after the handshake (previously always `nil` via the fallback).
- The implicit handshake path now validates the server's negotiated
protocol version, raising `RequestHandlerError` for unsupported versions.
Production servers that return one of the supported versions are unaffected;
this closes a pre-existing spec-conformance gap on stdio.