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.
This commit is contained in:
parent
d51d4a0f91
commit
d0d7308a89
1 changed files with 1203 additions and 151 deletions
|
|
@ -1,6 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "client/elicitation"
|
||||
require_relative "client/modern_envelope"
|
||||
require_relative "client/oauth"
|
||||
require_relative "client/stdio"
|
||||
require_relative "client/http"
|
||||
|
|
@ -79,6 +80,18 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# The server's `DiscoverResult` (MCP 2026-07-28, SEP-2575): the modern protocol versions it serves,
|
||||
# its capabilities and identity, optional instructions, and the REQUIRED `ttlMs`/`cacheScope` cache hints.
|
||||
DiscoverResult = Struct.new(
|
||||
:supported_versions,
|
||||
:capabilities,
|
||||
:server_info,
|
||||
:instructions,
|
||||
:ttl_ms,
|
||||
:cache_scope,
|
||||
keyword_init: true,
|
||||
)
|
||||
|
||||
# Initializes a new MCP::Client instance.
|
||||
#
|
||||
# @param transport [Object] The transport object to use for communication with the server.
|
||||
|
|
@ -95,14 +108,43 @@ module MCP
|
|||
# So keeping it public
|
||||
attr_reader :transport
|
||||
|
||||
# The server's `InitializeResult` (protocol version, capabilities, server info,
|
||||
# instructions), as reported by the transport after a successful `connect`.
|
||||
# Returns `nil` before `connect`, after `close`, or when the transport does
|
||||
# not expose a cached handshake result.
|
||||
# The raw handshake result exactly as the server returned it, so its shape depends on the connection's era (SEP-2575):
|
||||
# after the legacy handshake it is an `InitializeResult` (`protocolVersion`, top-level `serverInfo`), after modern adoption
|
||||
# it is a `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`, `serverInfo` optionally under `_meta`).
|
||||
# Code that must work against both eras should prefer the era-independent readers {#protocol_version}, {#server_capabilities},
|
||||
# {#instructions}, and {#server_implementation}; this raw form remains the window to everything they do not cover
|
||||
# (`supportedVersions`, cache hints, `_meta`, extension data). Returns `nil` before `connect`, after `close`,
|
||||
# or when the transport does not expose a cached handshake result.
|
||||
def server_info
|
||||
transport.server_info if transport.respond_to?(:server_info)
|
||||
end
|
||||
|
||||
# The protocol version in use on this connection, independent of its era:
|
||||
# the version negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
|
||||
# Returns `nil` before `connect`.
|
||||
def protocol_version
|
||||
return transport.protocol_version if transport.respond_to?(:protocol_version)
|
||||
|
||||
server_info&.dig("protocolVersion")
|
||||
end
|
||||
|
||||
# The server's capabilities Hash, present in both eras. Returns `nil` before `connect`.
|
||||
def server_capabilities
|
||||
server_info&.dig("capabilities")
|
||||
end
|
||||
|
||||
# The server's instructions text, present in both eras when provided.
|
||||
def instructions
|
||||
server_info&.dig("instructions")
|
||||
end
|
||||
|
||||
# The server's identity (`name`/`version`), independent of where the era puts it: top-level `serverInfo`
|
||||
# on legacy results, the optional `_meta` `io.modelcontextprotocol/serverInfo` stamp on modern results.
|
||||
# Returns `nil` when a modern server does not identify itself.
|
||||
def server_implementation
|
||||
server_info&.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || server_info&.dig("serverInfo")
|
||||
end
|
||||
|
||||
# Performs the MCP `initialize` handshake by delegating to the transport
|
||||
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
|
||||
# `InitializeResult`.
|
||||
|
|
@ -115,16 +157,62 @@ module MCP
|
|||
# @param capabilities [Hash] Capabilities advertised by the client. May include
|
||||
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
|
||||
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
|
||||
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
||||
# does not expose an explicit handshake.
|
||||
# @param mode [Symbol, nil] Lifecycle selection (SEP-2575). When omitted, transports whose
|
||||
# `connect` declares `mode:` (the bundled `MCP::Client::HTTP` and `MCP::Client::Stdio`)
|
||||
# negotiate with `:auto`: probe `server/discover` first and fall back to the legacy handshake
|
||||
# when the server does not serve a mutually supported modern version. Transports without `mode:`
|
||||
# keep receiving the historical legacy call shape. `:legacy` forces the `initialize` handshake
|
||||
# exactly as before; `:modern` requires the modern lifecycle and fails without a mutual modern version.
|
||||
# Passing an explicit `protocol_version` from a legacy generation (e.g. `"2025-11-25"`) pins
|
||||
# the legacy handshake without a probe, so an explicitly requested version is never overridden
|
||||
# by the default negotiation.
|
||||
# @return [Hash, nil] The server's `InitializeResult` (legacy) or `DiscoverResult` (modern),
|
||||
# or `nil` when the transport does not expose an explicit handshake.
|
||||
# Prefer the era-independent readers over inspecting this Hash directly.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
||||
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
||||
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: nil)
|
||||
return unless transport.respond_to?(:connect)
|
||||
|
||||
transport.connect(
|
||||
client_info: client_info,
|
||||
protocol_version: protocol_version,
|
||||
capabilities: capabilities,
|
||||
effective_mode = resolve_connect_mode(mode, protocol_version)
|
||||
|
||||
if effective_mode == :legacy
|
||||
transport.connect(
|
||||
client_info: client_info,
|
||||
protocol_version: protocol_version,
|
||||
capabilities: capabilities,
|
||||
)
|
||||
else
|
||||
transport.connect(
|
||||
client_info: client_info,
|
||||
protocol_version: protocol_version,
|
||||
capabilities: capabilities,
|
||||
mode: effective_mode,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery
|
||||
# that works before (or instead of) `connect`.
|
||||
#
|
||||
# @param meta [Hash, nil] Additional `_meta` entries to send with the request.
|
||||
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
||||
# @return [MCP::Client::DiscoverResult]
|
||||
# @raise [ServerError] If the server returns a JSON-RPC error.
|
||||
# @raise [ValidationError] If the response `result` is missing or not a Hash.
|
||||
def discover(meta: nil, cancellation: nil)
|
||||
response = request(method: Methods::SERVER_DISCOVER, meta: meta, cancellation: cancellation)
|
||||
result = response.is_a?(Hash) ? response["result"] : nil
|
||||
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
||||
|
||||
DiscoverResult.new(
|
||||
supported_versions: result["supportedVersions"],
|
||||
capabilities: result["capabilities"],
|
||||
# The finalized spec (PR #3002) stamps the server identity into the result `_meta`;
|
||||
# the top-level fallback tolerates servers built against the frozen SEP text.
|
||||
server_info: result.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || result["serverInfo"],
|
||||
instructions: result["instructions"],
|
||||
ttl_ms: result["ttlMs"],
|
||||
cache_scope: result["cacheScope"],
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -485,6 +573,46 @@ module MCP
|
|||
|
||||
private
|
||||
|
||||
# Resolves the effective SEP-2575 lifecycle mode for `connect`:
|
||||
#
|
||||
# - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe,
|
||||
# so the default negotiation can never override a version the caller asked for.
|
||||
# - Absent an explicit mode, transports declaring `mode:` negotiate with `:auto`; other transports keep
|
||||
# the historical legacy call shape.
|
||||
# - An explicit `:modern`/`:auto` on a transport without `mode:` raises, rather than silently downgrading to
|
||||
# a lifecycle the caller did not ask for.
|
||||
def resolve_connect_mode(mode, protocol_version)
|
||||
unless [nil, :legacy, :modern, :auto].include?(mode)
|
||||
raise ArgumentError, "mode must be :legacy, :modern, or :auto"
|
||||
end
|
||||
|
||||
return :legacy if mode == :legacy
|
||||
return :legacy if mode.nil? && protocol_version && !Configuration.modern_protocol_version?(protocol_version)
|
||||
|
||||
if transport_connect_accepts_mode?
|
||||
mode || :auto
|
||||
elsif mode.nil?
|
||||
:legacy
|
||||
else
|
||||
raise ArgumentError, "transport does not support mode: #{mode.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
# `mode:` is forwarded only when the transport's `connect` declares it as a keyword.
|
||||
# A bare `**kwargs` deliberately does not count, so wrappers and test doubles that
|
||||
# absorb arbitrary keywords keep the historical legacy call shape. Objects that
|
||||
# dispatch `connect` through `method_missing` (e.g. mocks) may not support `method`,
|
||||
# which reads as not declaring `mode:`.
|
||||
def transport_connect_accepts_mode?
|
||||
connect_method = begin
|
||||
transport.method(:connect)
|
||||
rescue NameError
|
||||
return false
|
||||
end
|
||||
|
||||
connect_method.parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :mode }
|
||||
end
|
||||
|
||||
# Walks every page of a list endpoint, following `next_cursor`, and returns
|
||||
# the page results. The `seen` set guards against a server that repeats or
|
||||
# cycles cursors, so the loop always terminates.
|
||||
|
|
|
|||
Loading…
Reference in a new issue