Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575 - #480
Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575#480koic wants to merge 1 commit into
server/discover and Add Client Modern Lifecycle Support per SEP-2575#480Conversation
…r 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. 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 modelcontextprotocol#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.
|
One note on naming, following the earlier discussion about the term "modern" (#471 (comment)): this PR carries it further into the user-facing API, for example in the README's
I avoided There may well be a better name. The cheapest time to change it is before this ships, so suggestions are welcome. |
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#discoverreaches its spec-final shape:supportedVersionsadvertises modern versions only, matching the TypeScript and Python SDKs: legacy versions are negotiated viainitialize, not selected from discovery. This also closes an interop hazard of the interim response: auto-negotiating TS and Python clients treat anyDiscoverResultas proof of a modern server and do not fall back toinitialize, so advertising only legacy versions made them fail outright.ttlMs/cacheScopecache hints are REQUIRED onDiscoverResult(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 withttl_ms/cache_scope, and the configured SEP-2549 values are reused otherwise.Client side, both bundled transports learn the modern lifecycle, and
MCP::Clientnegotiates it automatically by default:MCP::Client#discoversendsserver/discoverand returns aDiscoverResultstruct (supported_versions,capabilities,server_info,instructions,ttl_ms,cache_scope). It works beforeconnect; the stdio transport's connect guard exemptsserver/discoverand spawns the subprocess on demand.connectgainsmode:and defaults to automatic negotiation, joining the Python, Go, and C# SDKs (the TypeScript SDK keeps a legacy default): transports whoseconnectdeclaresmode:(the bundled HTTP and stdio transports) receive:auto, which probesserver/discoverand 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 whoseconnectdoes not declaremode:keep receiving the historical legacy call shape, detected by keyword introspection; a bare**kwargsdeliberately does not count, so wrappers and test doubles that absorb arbitrary keywords stay on the legacy shape. An explicit:modern/:autoagainst such a transport raisesArgumentErrorinstead of silently downgrading.:legacy/:auto(shared with the Python and TypeScript SDKs) plus an explicit:modern, instead of folding a version-string pin intomodeas Python and TypeScript do: this client has carried the scalarprotocol_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 andprotocol_version:stays the version, the same orthogonal split as the Go and C# scalar version pins; Python'smode="2026-07-28"is expressed here asmode: :modernwithprotocol_version: "2026-07-28".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: :legacyforces 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.connectreturn value andserver_infomirror the wire result, their shape depends on the negotiated era (InitializeResultvsDiscoverResult). 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_metaio.modelcontextprotocol/serverInfostamp with a top-level fallback and returnsnilwhen a modern server does not identify itself.server_inforemains the permanent raw window to everything the readers do not cover.server/discoverprobe is bounded by a newDEFAULT_DISCOVER_PROBE_TIMEOUT(5 seconds, matching the C# SDK) when noread_timeoutwas configured: a compliant legacy server answers the probe with-32601immediately, but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback instead of blockingconnectforever. Regular requests keep the unbounded default.MCP::Client::ModernEnvelopestamps the SEP-2575_metatriple onto every request (never onto notifications, whose_metacarries no envelope), reusing the reserved key names fromMCP::RequestEnvelope. The HTTP transport sends the matchingMCP-Protocol-Versionheader and keeps the existingMcp-Method/Mcp-Namemirror headers; noMcp-Session-Idis ever attached becauseinitializeis never sent.mode: :legacy: the referee validates the legacyinitializehandshake and its scenario servers do not expect a preceding probe.Resolves #389.
How Has This Been Tested?
New tests in
test/mcp/server_test.rbpin the required cache hints (spec defaults and configured SEP-2549 values); the existing discover assertions acrossserver_test.rband both transport test files were updated to the modern-onlysupportedVersions.New tests in
test/mcp/client_test.rbcover theDiscoverResultstruct mapping,ValidationError/ServerErroron malformed and error responses, and the mode resolution contract: the:autodefault on transports declaringmode:, the unchanged legacy call shape for transports without it, the legacy pin for an explicit stableprotocol_version,ArgumentErrorfor explicit non-legacy modes on transports withoutmode:, unknown-mode rejection, and the era-independent readers against legacy results, modern results, the_metaserverInfostamp, and absence.New tests in
test/mcp/client/http_test.rbcover 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 intest/mcp/client/stdio_test.rbcover pre-connectserver/discover, modern adoption with envelope stamping and noinitializeframe, 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/discoverresponses change shape (modern-onlysupportedVersionsplus the requiredttlMs/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#connectwithoutmode:now probesserver/discoverbefore 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 fromInitializeResulttoDiscoverResult. Passmode: :legacyor an explicit legacy-generationprotocol_versionto keep the previous behavior unconditionally, or use the era-independent readers, which are stable across both lifecycles. Custom transports withoutmode:and directtransport.connectcalls are unaffected.Types of changes
Checklist