## Motivation and Context
Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation,
both introduced against the frozen SEP text and overtaken by post-final spec changes:
1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it
optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only
the `protocolVersion` + `clientCapabilities` pair.
A conformant client configured not to identify itself was rejected.
2. A missing or mistyped envelope answered `-32600` Invalid Request.
The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer
with `-32602` naming the offending keys.
Fixing these also corrects the era classification to match the reference SDKs: a request claims
the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion`
(the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks),
and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming
the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without
the claim key (`progressToken`, trace context) is classified exactly as before.
Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on
a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK.
## How Has This Been Tested?
`test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone,
a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo`
(reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities`
and for mistyped required and optional fields.
`test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed
but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is
served with `server_context.client_info` reading `nil`, and a claim-less request on
a modern-locked session answers `-32602` naming the required keys.
The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths.
## Breaking Changes
None for conforming clients. Requests that were already rejected change error code
(`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key
with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests.
Envelopes without `clientInfo`, previously rejected, now succeed.
## 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.
## 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.