Skip to content

Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575 - #480

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:discover_finalize_and_client
Open

Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575#480
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:discover_finalize_and_client

Conversation

@koic

@koic koic commented Aug 4, 2026

Copy link
Copy Markdown
Member

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 #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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

…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.
@koic

koic commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

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 client.connect(mode: :modern).

mode: and the existing protocol_version: are orthogonal parameters: mode: selects the negotiation strategy, while protocol_version: selects the protocol version, e.g. client.connect(protocol_version: "2026-07-28", mode: :modern).

:legacy and :auto use the same names as the Python and TypeScript SDKs. Neither SDK needs a third name because the modern-only case is expressed by pinning the protocol version, which the Ruby client cannot do without making it ambiguous which of the two version-bearing inputs takes precedence. That requires a third keyword, and :modern follows the reference SDKs' internal era terminology (ProtocolEra = 'legacy' | 'modern' in TypeScript and MODERN_PROTOCOL_VERSIONS in Python).

I avoided :stateless because the transports already expose a stateless: option with a different meaning (SEP-2567 ephemeral sessions), and overloading the same term across the two SEPs seemed more confusing.

There may well be a better name. The cheapest time to change it is before this ships, so suggestions are welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEP-2575: Make MCP Stateless

1 participant