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
|
|
@ -6,6 +6,7 @@ require_relative "../configuration"
|
|||
require_relative "../methods"
|
||||
require_relative "../protocol_deprecations"
|
||||
require_relative "../version"
|
||||
require_relative "modern_envelope"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
|
|
@ -252,6 +253,8 @@ module MCP
|
|||
@connected = false
|
||||
@server_request_handlers = {}
|
||||
@listener_thread = nil
|
||||
@modern_client_info = nil
|
||||
@modern_capabilities = nil
|
||||
end
|
||||
|
||||
# Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream.
|
||||
|
|
@ -287,73 +290,37 @@ module MCP
|
|||
# @return [Hash] The server's `InitializeResult`.
|
||||
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
||||
# or a malformed result.
|
||||
# @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
|
||||
# the handshake below, `:modern` skips it and probes `server/discover`,
|
||||
# and `:auto` probes `server/discover` first, falling back to the legacy handshake
|
||||
# when the server does not serve a mutually supported modern version.
|
||||
# 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: :legacy)
|
||||
return @server_info if connected?
|
||||
|
||||
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
||||
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
||||
|
||||
response = send_request(request: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
id: SecureRandom.uuid,
|
||||
method: MCP::Methods::INITIALIZE,
|
||||
params: {
|
||||
protocolVersion: protocol_version,
|
||||
capabilities: capabilities,
|
||||
clientInfo: client_info,
|
||||
},
|
||||
})
|
||||
|
||||
if response.is_a?(Hash) && response.key?("error")
|
||||
clear_session
|
||||
error = response["error"]
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: #{error["message"]}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
case mode
|
||||
when :legacy
|
||||
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
||||
when :modern
|
||||
connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
||||
when :auto
|
||||
connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
||||
else
|
||||
raise ArgumentError, "mode must be :legacy, :modern, or :auto"
|
||||
end
|
||||
end
|
||||
|
||||
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
|
||||
clear_session
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: missing result in response",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
@server_info = response["result"]
|
||||
negotiated_protocol_version = @server_info["protocolVersion"]
|
||||
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
||||
clear_session
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
|
||||
|
||||
begin
|
||||
send_request(request: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
||||
})
|
||||
rescue StandardError
|
||||
clear_session
|
||||
raise
|
||||
end
|
||||
|
||||
@connected = true
|
||||
start_listening if @server_request_handlers.any?
|
||||
@server_info
|
||||
# Whether the transport operates in the stateless modern lifecycle (SEP-2575):
|
||||
# no handshake was performed and every request carries the `_meta` envelope.
|
||||
def modern?
|
||||
!@modern_client_info.nil?
|
||||
end
|
||||
|
||||
# Returns true once `connect` has completed the full handshake
|
||||
# (`initialize` response received and `notifications/initialized` sent).
|
||||
# (`initialize` response received and `notifications/initialized` sent),
|
||||
# or once the modern lifecycle was adopted via `server/discover`.
|
||||
# Returns false before the first handshake and after `close`.
|
||||
def connected?
|
||||
@connected
|
||||
|
|
@ -373,6 +340,18 @@ module MCP
|
|||
# a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
|
||||
def send_request(request:)
|
||||
# Modern requests (never notifications, whose `_meta` has no envelope) carry
|
||||
# the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers`
|
||||
# matches it by construction.
|
||||
if modern? && (request[:id] || request["id"])
|
||||
request = ModernEnvelope.stamp(
|
||||
request,
|
||||
protocol_version: @protocol_version,
|
||||
client_info: @modern_client_info,
|
||||
capabilities: @modern_capabilities,
|
||||
)
|
||||
end
|
||||
|
||||
method = request[:method] || request["method"]
|
||||
params = request[:params] || request["params"]
|
||||
oauth_retried = false
|
||||
|
|
@ -535,6 +514,153 @@ module MCP
|
|||
|
||||
attr_reader :headers
|
||||
|
||||
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
||||
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
||||
|
||||
response = send_request(request: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
id: SecureRandom.uuid,
|
||||
method: MCP::Methods::INITIALIZE,
|
||||
params: {
|
||||
protocolVersion: protocol_version,
|
||||
capabilities: capabilities,
|
||||
clientInfo: client_info,
|
||||
},
|
||||
})
|
||||
|
||||
if response.is_a?(Hash) && response.key?("error")
|
||||
clear_session
|
||||
error = response["error"]
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: #{error["message"]}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
|
||||
clear_session
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: missing result in response",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
@server_info = response["result"]
|
||||
negotiated_protocol_version = @server_info["protocolVersion"]
|
||||
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
||||
clear_session
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
|
||||
|
||||
begin
|
||||
send_request(request: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
||||
})
|
||||
rescue StandardError
|
||||
clear_session
|
||||
raise
|
||||
end
|
||||
|
||||
@connected = true
|
||||
start_listening if @server_request_handlers.any?
|
||||
@server_info
|
||||
end
|
||||
|
||||
# Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
|
||||
# No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
|
||||
def connect_modern(client_info:, protocol_version:, capabilities:)
|
||||
version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
|
||||
unless MCP::Configuration.modern_protocol_version?(version)
|
||||
raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
|
||||
end
|
||||
|
||||
enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities)
|
||||
|
||||
begin
|
||||
result = probe_discover
|
||||
rescue StandardError
|
||||
leave_modern_mode
|
||||
raise
|
||||
end
|
||||
|
||||
supported = result["supportedVersions"]
|
||||
unless supported.is_a?(Array) && supported.include?(version)
|
||||
leave_modern_mode
|
||||
raise RequestHandlerError.new(
|
||||
"Server discovery failed: no mutually supported modern protocol version " \
|
||||
"(server supports #{supported.inspect})",
|
||||
{ method: MCP::Methods::SERVER_DISCOVER },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
@server_info = result
|
||||
@connected = true
|
||||
@server_info
|
||||
end
|
||||
|
||||
# Probes `server/discover` and adopts the modern lifecycle when the server serves
|
||||
# a mutually supported modern version; otherwise falls back to the legacy handshake.
|
||||
# The fallback intentionally covers a successful discovery without a mutual modern
|
||||
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
||||
# only serving legacy versions.
|
||||
def connect_auto(client_info:, protocol_version:, capabilities:)
|
||||
connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities)
|
||||
rescue RequestHandlerError
|
||||
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
||||
end
|
||||
|
||||
def enter_modern_mode(protocol_version:, client_info:, capabilities:)
|
||||
@modern_client_info = client_info
|
||||
@modern_capabilities = capabilities || {}
|
||||
# `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header,
|
||||
# which the modern lifecycle requires to match the `_meta`-carried version.
|
||||
@protocol_version = protocol_version
|
||||
end
|
||||
|
||||
def leave_modern_mode
|
||||
@modern_client_info = nil
|
||||
@modern_capabilities = nil
|
||||
@protocol_version = nil
|
||||
end
|
||||
|
||||
def probe_discover
|
||||
response = send_request(request: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
id: SecureRandom.uuid,
|
||||
method: MCP::Methods::SERVER_DISCOVER,
|
||||
})
|
||||
|
||||
if response.is_a?(Hash) && response.key?("error")
|
||||
error = response["error"]
|
||||
raise RequestHandlerError.new(
|
||||
"Server discovery failed: #{error["message"]}",
|
||||
{ method: MCP::Methods::SERVER_DISCOVER },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
result = response.is_a?(Hash) ? response["result"] : nil
|
||||
unless result.is_a?(Hash)
|
||||
raise RequestHandlerError.new(
|
||||
"Server discovery failed: missing result in response",
|
||||
{ method: MCP::Methods::SERVER_DISCOVER },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def client
|
||||
require_faraday!
|
||||
@client ||= Faraday.new(url) do |faraday|
|
||||
|
|
@ -730,6 +856,8 @@ module MCP
|
|||
@protocol_version = nil
|
||||
@server_info = nil
|
||||
@connected = false
|
||||
@modern_client_info = nil
|
||||
@modern_capabilities = nil
|
||||
end
|
||||
|
||||
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
|
||||
|
|
|
|||
Loading…
Reference in a new issue