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:
Koichi ITO 2026-08-04 03:35:35 +09:00
commit d0d7308a89
14 changed files with 1203 additions and 151 deletions

View file

@ -46,8 +46,14 @@ It implements the Model Context Protocol specification, handling model context r
### Supported Methods
- `initialize` - Initializes the protocol and returns server capabilities
- `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`,
and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id`
- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the modern `supportedVersions`,
`capabilities`, `instructions`, the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional
`io.modelcontextprotocol/serverInfo` stamp in the result `_meta`, and responds before `initialize`
and without an `Mcp-Session-Id`. The server also serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope
(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request,
and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates
the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips
the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result
- `ping` - Simple health check
- `logging/setLevel` - Configures the minimum log level for the server
- `tools/list` - Lists all registered tools and their schemas
@ -2376,6 +2382,41 @@ This class supports:
Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
Authorization is handled by the transport layer.
### Lifecycle Negotiation (SEP-2575)
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes `server/discover` first and adopts
the stateless modern lifecycle (MCP 2026-07-28) when the server serves it, falling back to
the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare
a `mode:` keyword always receive the classic call shape, unchanged.
```ruby
client.connect # negotiate automatically (default)
client.connect(mode: :legacy) # force the classic initialize handshake
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe
```
Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process)
and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on
the legacy lifecycle.
Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result,
their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`,
top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`)
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
```ruby
client.protocol_version # negotiated or adopted version, either lifecycle
client.server_capabilities # capabilities Hash, either lifecycle
client.instructions # instructions text, either lifecycle
client.server_implementation # server name/version; nil when a modern server does not identify itself
```
Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded,
the server now serves the modern lifecycle and the automatic negotiation adopted it.
Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
## Transport Layer Interface
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:

View file

@ -171,9 +171,12 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation
# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
# and a failing exit status.
begin
# The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle:
# the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect.
client.connect(
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
capabilities: capabilities,
mode: :legacy,
)
case scenario

View file

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

View file

@ -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

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
require_relative "../request_envelope"
module MCP
class Client
# Stamps the SEP-2575 per-request `_meta` envelope onto outgoing modern requests.
# The reserved key names are shared with the server-side `MCP::RequestEnvelope`.
# Reserved keys always win over caller-supplied `_meta` entries because they are
# wire vocabulary the SDK is responsible for; every other entry is preserved.
module ModernEnvelope
extend self
# Returns a copy of `request` with `params._meta` carrying the modern triple.
# Neither `request` nor its nested hashes are mutated.
def stamp(request, protocol_version:, client_info:, capabilities:)
params_key = request.key?("params") ? "params" : :params
params = request[params_key] || {}
meta_key = params.key?("_meta") ? "_meta" : :_meta
meta = params[meta_key] || {}
stamped_meta = meta.merge(
RequestEnvelope::PROTOCOL_VERSION_META_KEY.to_sym => protocol_version,
RequestEnvelope::CLIENT_INFO_META_KEY.to_sym => client_info,
RequestEnvelope::CLIENT_CAPABILITIES_META_KEY.to_sym => capabilities,
)
request.merge(params_key => params.merge(meta_key => stamped_meta))
end
end
end
end

View file

@ -9,6 +9,7 @@ require_relative "../configuration"
require_relative "../methods"
require_relative "../protocol_deprecations"
require_relative "../version"
require_relative "modern_envelope"
module MCP
class Client
@ -27,6 +28,12 @@ module MCP
# realistic JSON-RPC frame, including base64-embedded images.
MAX_LINE_BYTES = 4 * 1024 * 1024
# Seconds the `server/discover` probe may wait 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 would otherwise block `connect(mode: :auto)` forever.
# Matches the C# SDK's `DiscoverProbeTimeout` default.
DEFAULT_DISCOVER_PROBE_TIMEOUT = 5
attr_reader :command, :args, :env, :server_info
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
@ -50,6 +57,9 @@ module MCP
@started = false
@initialized = false
@server_info = nil
@modern_protocol_version = nil
@modern_client_info = nil
@modern_capabilities = nil
# Serializes writes to `@stdin` so a request line and a notification line emitted from
# different threads (e.g. cancellation) cannot interleave on the wire.
@write_mutex = Mutex.new
@ -73,82 +83,47 @@ module MCP
# @return [Hash] The server's `InitializeResult`.
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
# a malformed result, or an unsupported protocol version.
# @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: {})
return @server_info if @initialized
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
return @server_info if connected?
start unless @started
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
init_request = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::INITIALIZE,
params: {
protocolVersion: protocol_version,
capabilities: capabilities,
clientInfo: client_info,
},
}
write_message(init_request)
response = read_response(init_request)
if response.key?("error")
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
unless response["result"].is_a?(Hash)
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)
# Per spec, if the client does not support the server's returned protocol version,
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
# raising so a retry starts without a stale `server_info`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
@server_info = nil
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
notification = {
jsonrpc: JsonRpcHandler::Version::V2_0,
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
}
write_message(notification)
rescue StandardError
@server_info = nil
raise
end
@initialized = true
@server_info
end
# Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
# 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
# The protocol version in use on this connection, independent of its era:
# negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
# Returns `nil` before `connect` and after `close`.
def protocol_version
@modern_protocol_version || (@server_info && @server_info["protocolVersion"])
end
# Returns true once `connect` has completed the handshake or adopted the modern lifecycle.
# Returns false before the handshake and after `close`.
def connected?
@initialized
@initialized || modern?
end
# Transports may yield once the request line has been written to `@stdin`.
@ -156,7 +131,16 @@ module MCP
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
def send_request(request:)
raise "MCP::Client#connect must be called before sending requests." unless @initialized
method = request[:method] || request["method"]
if method == MCP::Methods::SERVER_DISCOVER
# `server/discover` (SEP-2575) is sessionless capability discovery that
# works before (or instead of) `connect`.
start unless @started
elsif !connected?
raise "MCP::Client#connect must be called before sending requests."
end
request = stamp_modern(request)
@write_mutex.synchronize do
write_message(request)
@ -169,7 +153,7 @@ module MCP
# `notifications/cancelled` for an in-flight request.
def send_notification(notification:)
start unless @started
connect unless @initialized
connect unless connected?
@write_mutex.synchronize { write_message(notification) }
nil
@ -231,10 +215,187 @@ module MCP
@started = false
@initialized = false
@server_info = nil
leave_modern_mode
end
private
def connect_legacy(client_info:, protocol_version:, capabilities:)
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
init_request = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::INITIALIZE,
params: {
protocolVersion: protocol_version,
capabilities: capabilities,
clientInfo: client_info,
},
}
write_message(init_request)
response = read_response(init_request)
if response.key?("error")
error = response["error"]
raise RequestHandlerError.new(
"Server initialization failed: #{error["message"]}",
{ method: MCP::Methods::INITIALIZE },
error_type: :internal_error,
)
end
unless response["result"].is_a?(Hash)
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)
# Per spec, if the client does not support the server's returned protocol version,
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before raising
# so a retry starts without a stale `server_info`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
@server_info = nil
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
notification = {
jsonrpc: JsonRpcHandler::Version::V2_0,
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
}
write_message(notification)
rescue StandardError
@server_info = nil
raise
end
@initialized = true
@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
@modern_protocol_version = version
@modern_client_info = client_info
@modern_capabilities = capabilities || {}
begin
result = with_probe_read_timeout { 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
@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 leave_modern_mode
@modern_protocol_version = nil
@modern_client_info = nil
@modern_capabilities = nil
end
# Bounds the probe read when the caller configured no `read_timeout`, and only for the probe:
# regular requests keep the unbounded default so long-running tools are unaffected.
# The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence
# and falls back on.
def with_probe_read_timeout
return yield if @read_timeout
@read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT
begin
yield
ensure
@read_timeout = nil
end
end
def probe_discover
request = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::SERVER_DISCOVER,
}
@write_mutex.synchronize { write_message(stamp_modern(request)) }
response = read_response(request)
if 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["result"]
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
# Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple.
def stamp_modern(request)
return request unless modern? && (request[:id] || request["id"])
ModernEnvelope.stamp(
request,
protocol_version: @modern_protocol_version,
client_info: @modern_client_info,
capabilities: @modern_capabilities,
)
end
def write_message(message)
ensure_running!
json = JSON.generate(message)

View file

@ -17,6 +17,11 @@ module MCP
# Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format.
LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel"
# Result-side counterpart of the request envelope: the server's identity rides in
# the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was
# finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it.
SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
REQUIRED_META_KEYS = [
PROTOCOL_VERSION_META_KEY,
CLIENT_INFO_META_KEY,

View file

@ -767,20 +767,26 @@ module MCP
end
end
# Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery.
# Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery.
# Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
# the session initialized, and responds regardless of capability declarations or initialization state,
# so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered
# because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints
# are not included here yet.
# so clients can probe a server before (or instead of) `initialize`.
#
# `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
# legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope`
# cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results),
# so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only)
# fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides
# in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized
# spec (PR #3002), unfiltered because discovery happens before version negotiation.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
def discover(_request)
{
supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
capabilities: capabilities,
serverInfo: server_info,
instructions: instructions,
}.compact
_meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
}.compact.merge(ttlMs: ttl_ms || 0, cacheScope: cache_scope || "private")
end
def configure_logging_level(request, session: nil)

View file

@ -1844,6 +1844,109 @@ module MCP
refute_predicate(client, :connected?)
end
def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle
discover_stub = stub_discover
result = client.connect(mode: :modern)
assert_requested(discover_stub)
assert_predicate(client, :connected?)
assert_predicate(client, :modern?)
assert_equal(["2026-07-28"], result["supportedVersions"])
assert_equal(result, client.server_info)
assert_equal("2026-07-28", client.protocol_version)
end
def test_modern_requests_carry_the_envelope_and_matching_header
stub_discover
client.connect(
mode: :modern,
client_info: { name: "my-app", version: "9.9" },
capabilities: { elicitation: {} },
)
request_stub = stub_request(:post, url).with do |req|
body = JSON.parse(req.body)
meta = body.dig("params", "_meta")
body["method"] == "tools/list" &&
req.headers["Mcp-Protocol-Version"] == "2026-07-28" &&
meta == {
"io.modelcontextprotocol/protocolVersion" => "2026-07-28",
"io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" },
"io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} },
}
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { tools: [] } }.to_json,
)
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_requested(request_stub)
end
def test_connect_modern_fails_without_a_mutual_modern_version
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
)
error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) }
assert_includes(error.message, "no mutually supported modern protocol version")
refute_predicate(client, :connected?)
refute_predicate(client, :modern?)
end
def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { error: { code: -32601, message: "Method not found" } }.to_json,
)
init_stub = stub_initialize
notification_stub = stub_notification
result = client.connect(mode: :auto)
assert_requested(init_stub)
assert_requested(notification_stub)
refute_predicate(client, :modern?)
assert_predicate(client, :connected?)
assert_equal("2025-11-25", result["protocolVersion"])
end
def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version
# Rollout tolerance: a server may answer discovery while only serving legacy versions;
# auto mode falls back to the legacy handshake.
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
)
init_stub = stub_initialize
stub_notification
client.connect(mode: :auto)
assert_requested(init_stub)
refute_predicate(client, :modern?)
assert_predicate(client, :connected?)
end
def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode
assert_raises(ArgumentError) { client.connect(mode: :bogus) }
assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") }
end
def test_reconnect_after_close
stub_initialize
stub_notification
@ -1949,6 +2052,24 @@ module MCP
.to_return(status: 202, body: "")
end
def stub_discover
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: {
result: {
supportedVersions: ["2026-07-28"],
capabilities: { tools: {} },
_meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } },
ttlMs: 0,
cacheScope: "private",
},
}.to_json,
)
end
def stub_request(method, url)
WebMock.stub_request(method, url)
end

View file

@ -1414,6 +1414,198 @@ module MCP
[transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]]
end
def test_send_request_allows_server_discover_before_connect
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
server_thread = Thread.new do
discover_request = JSON.parse(stdin_read.gets)
response = {
jsonrpc: "2.0",
id: discover_request["id"],
result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" },
}
stdout_write.puts(JSON.generate(response))
stdout_write.flush
end
# No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery.
response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" })
assert_equal(["2026-07-28"], response.dig("result", "supportedVersions"))
refute_predicate(transport, :connected?)
ensure
server_thread&.join
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
io.close unless io.closed?
end
end
def test_connect_modern_probes_discover_and_stamps_requests
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
received = []
server_thread = Thread.new do
discover_request = JSON.parse(stdin_read.gets)
received << discover_request
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: discover_request["id"],
result: {
supportedVersions: ["2026-07-28"],
capabilities: {},
serverInfo: { name: "test-server", version: "1.0" },
ttlMs: 0,
cacheScope: "private",
},
}))
stdout_write.flush
tools_request = JSON.parse(stdin_read.gets)
received << tools_request
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } }))
stdout_write.flush
end
result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" })
response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" })
assert_equal(["2026-07-28"], result["supportedVersions"])
assert_predicate(transport, :modern?)
assert_predicate(transport, :connected?)
assert_equal("2026-07-28", transport.protocol_version)
assert_equal("t1", response["id"])
# No `initialize` was sent; both frames carry the SEP-2575 envelope.
assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] })
triple = received[1].dig("params", "_meta")
assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"])
assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"])
assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"])
ensure
server_thread&.join
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
io.close unless io.closed?
end
end
def test_connect_auto_falls_back_to_the_legacy_handshake
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
server_thread = Thread.new do
discover_request = JSON.parse(stdin_read.gets)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: discover_request["id"],
error: { code: -32601, message: "Method not found" },
}))
stdout_write.flush
init_request = JSON.parse(stdin_read.gets)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: init_request["id"],
result: {
protocolVersion: "2025-11-25",
capabilities: {},
serverInfo: { name: "test-server", version: "1.0" },
},
}))
stdout_write.flush
# `notifications/initialized`
stdin_read.gets
end
result = transport.connect(mode: :auto)
assert_equal("2025-11-25", result["protocolVersion"])
assert_equal("2025-11-25", transport.protocol_version)
refute_predicate(transport, :modern?)
assert_predicate(transport, :connected?)
ensure
server_thread&.join
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
io.close unless io.closed?
end
end
def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured
transport = Stdio.new(command: "ruby", args: ["server.rb"])
transport.stubs(:start)
observed_timeout = nil
transport.define_singleton_method(:probe_discover) do
observed_timeout = @read_timeout
raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error)
end
transport.define_singleton_method(:connect_legacy) do |**|
{ "protocolVersion" => "2025-11-25" }
end
result = transport.connect(mode: :auto)
assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout)
assert_nil(transport.instance_variable_get(:@read_timeout))
assert_equal("2025-11-25", result["protocolVersion"])
end
def test_connect_auto_falls_back_when_the_server_never_answers_the_probe
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
# A configured `read_timeout` also bounds the probe; the server below reads
# the `server/discover` request and never responds, which must count as
# legacy evidence instead of blocking `connect` forever.
transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05)
server_thread = Thread.new do
# Silently swallow the probe.
stdin_read.gets
init_request = JSON.parse(stdin_read.gets)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: init_request["id"],
result: {
protocolVersion: "2025-11-25",
capabilities: {},
serverInfo: { name: "test-server", version: "1.0" },
},
}))
stdout_write.flush
# `notifications/initialized`
stdin_read.gets
end
result = transport.connect(mode: :auto)
assert_equal("2025-11-25", result["protocolVersion"])
refute_predicate(transport, :modern?)
ensure
server_thread&.join
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
io.close unless io.closed?
end
end
def mock_wait_thread
thread = mock("wait_thread")
thread.stubs(:alive?).returns(true)

View file

@ -85,6 +85,218 @@ module MCP
assert_includes(error.message, "does not support server-to-client requests")
end
# A transport that declares `mode:` on `connect`, recording what it received.
# The `:not_forwarded` sentinel distinguishes "Client did not forward mode" from
# "Client forwarded mode: :legacy".
class ModeAwareTransport
attr_reader :received
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :not_forwarded)
@received = {
client_info: client_info,
protocol_version: protocol_version,
capabilities: capabilities,
mode: mode,
}
{ "supportedVersions" => ["2026-07-28"] }
end
end
def test_connect_defaults_to_auto_on_transports_declaring_mode
transport = ModeAwareTransport.new
Client.new(transport: transport).connect
assert_equal(:auto, transport.received[:mode])
end
def test_connect_keeps_the_legacy_call_shape_for_transports_without_mode
# Custom transports whose `connect` does not declare `mode:` keep receiving
# the historical call shape, so the `:auto` default cannot break them.
transport = mock
transport.expects(:connect).with(
client_info: nil, protocol_version: nil, capabilities: {},
).returns({ "protocolVersion" => "2025-11-25" }).once
Client.new(transport: transport).connect
end
def test_connect_routes_explicit_legacy_through_the_legacy_call_shape
transport = ModeAwareTransport.new
Client.new(transport: transport).connect(mode: :legacy)
assert_equal(:not_forwarded, transport.received[:mode])
end
def test_connect_pins_legacy_for_an_explicit_stable_protocol_version
# An explicitly requested legacy-generation version must never be overridden
# by the default negotiation adopting the modern lifecycle.
transport = ModeAwareTransport.new
Client.new(transport: transport).connect(protocol_version: "2025-11-25")
assert_equal(:not_forwarded, transport.received[:mode])
assert_equal("2025-11-25", transport.received[:protocol_version])
end
def test_connect_negotiates_automatically_for_an_explicit_modern_protocol_version
transport = ModeAwareTransport.new
Client.new(transport: transport).connect(protocol_version: "2026-07-28")
assert_equal(:auto, transport.received[:mode])
end
def test_connect_raises_for_explicit_non_legacy_mode_on_a_transport_without_mode
transport = mock
transport.expects(:connect).never
error = assert_raises(ArgumentError) do
Client.new(transport: transport).connect(mode: :modern)
end
assert_includes(error.message, "does not support mode:")
end
def test_connect_rejects_unknown_modes
transport = ModeAwareTransport.new
assert_raises(ArgumentError) do
Client.new(transport: transport).connect(mode: :bogus)
end
end
def test_discover_returns_a_discover_result
transport = mock
transport.expects(:send_request).with do |args|
args[:request][:method] == "server/discover"
end.returns({
"result" => {
"supportedVersions" => ["2026-07-28"],
"capabilities" => { "tools" => {} },
"_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "test-server", "version" => "1.0" } },
"instructions" => "Optional instructions",
"ttlMs" => 3_600_000,
"cacheScope" => "public",
},
}).once
result = Client.new(transport: transport).discover
assert_equal(["2026-07-28"], result.supported_versions)
assert_equal({ "tools" => {} }, result.capabilities)
assert_equal({ "name" => "test-server", "version" => "1.0" }, result.server_info)
assert_equal("Optional instructions", result.instructions)
assert_equal(3_600_000, result.ttl_ms)
assert_equal("public", result.cache_scope)
end
def test_discover_maps_a_top_level_server_info_as_a_fallback
# Servers built against the frozen SEP text still stamp `serverInfo` at the top level;
# the finalized spec moved it into the result `_meta`.
transport = mock
transport.expects(:send_request).returns({
"result" => {
"supportedVersions" => ["2026-07-28"],
"serverInfo" => { "name" => "sep-era-server", "version" => "1.0" },
},
}).once
result = Client.new(transport: transport).discover
assert_equal({ "name" => "sep-era-server", "version" => "1.0" }, result.server_info)
end
def test_discover_raises_validation_error_on_missing_result
transport = mock
transport.expects(:send_request).returns({}).once
assert_raises(Client::ValidationError) do
Client.new(transport: transport).discover
end
end
def test_discover_raises_server_error_on_jsonrpc_error
transport = mock
transport.expects(:send_request).returns({
"error" => { "code" => -32601, "message" => "Method not found" },
}).once
error = assert_raises(Client::ServerError) do
Client.new(transport: transport).discover
end
assert_equal(-32601, error.code)
end
def test_era_independent_readers_on_a_legacy_result
transport = mock
transport.stubs(:server_info).returns({
"protocolVersion" => "2025-11-25",
"capabilities" => { "tools" => {} },
"serverInfo" => { "name" => "legacy-server", "version" => "1.0" },
"instructions" => "Be helpful",
})
client = Client.new(transport: transport)
assert_equal("2025-11-25", client.protocol_version)
assert_equal({ "tools" => {} }, client.server_capabilities)
assert_equal("Be helpful", client.instructions)
assert_equal({ "name" => "legacy-server", "version" => "1.0" }, client.server_implementation)
end
def test_era_independent_readers_on_a_modern_result
# A modern `DiscoverResult` has no `protocolVersion`; the adopted version comes
# from the transport. `serverInfo` sits in the result `_meta` per the final spec.
transport = mock
transport.stubs(:protocol_version).returns("2026-07-28")
transport.stubs(:server_info).returns({
"supportedVersions" => ["2026-07-28"],
"capabilities" => { "tools" => {} },
"instructions" => "Be helpful",
"_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "modern-server", "version" => "2.0" } },
"ttlMs" => 0,
"cacheScope" => "private",
})
client = Client.new(transport: transport)
assert_equal("2026-07-28", client.protocol_version)
assert_equal({ "tools" => {} }, client.server_capabilities)
assert_equal("Be helpful", client.instructions)
assert_equal({ "name" => "modern-server", "version" => "2.0" }, client.server_implementation)
end
def test_server_implementation_falls_back_to_the_top_level_and_tolerates_absence
transport = mock
transport.stubs(:server_info).returns({
"supportedVersions" => ["2026-07-28"],
"serverInfo" => { "name" => "top-level", "version" => "1.0" },
})
assert_equal({ "name" => "top-level", "version" => "1.0" }, Client.new(transport: transport).server_implementation)
anonymous = mock
anonymous.stubs(:server_info).returns({ "supportedVersions" => ["2026-07-28"] })
assert_nil(Client.new(transport: anonymous).server_implementation)
end
def test_era_independent_readers_return_nil_before_connect
transport = mock
transport.stubs(:respond_to?).with(:server_info).returns(false)
transport.stubs(:respond_to?).with(:protocol_version).returns(false)
client = Client.new(transport: transport)
assert_nil(client.protocol_version)
assert_nil(client.server_capabilities)
assert_nil(client.instructions)
assert_nil(client.server_implementation)
end
def test_connected_delegates_to_transport_when_supported
transport = mock
transport.expects(:connected?).returns(true)

View file

@ -583,7 +583,7 @@ module MCP
modern_ping_request(id: 3),
])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal :modern, session_era
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)

View file

@ -660,7 +660,7 @@ module MCP
assert_equal 200, response[0]
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
refute response[1].key?("mcp-session-id")
end
@ -678,7 +678,7 @@ module MCP
assert_equal 200, response[0]
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
end
test "rejects duplicate SSE connection with 409" do
@ -5426,7 +5426,7 @@ module MCP
assert_equal 200, response[0]
refute response[1].key?("mcp-session-id")
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
end
test "legacy POST rejects a modern envelope body without the modern header as -32020" do

View file

@ -129,10 +129,13 @@ module MCP
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
result = response[:result]
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, result[:supportedVersions]
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions]
assert_equal @server.capabilities, result[:capabilities]
assert_equal @server_name, result.dig(:serverInfo, :name)
assert_equal "1.2.3", result.dig(:serverInfo, :version)
# Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp,
# not a top-level `serverInfo` field.
assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name)
assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version)
refute result.key?(:serverInfo)
assert_equal "Optional instructions for the client", result[:instructions]
end
@ -142,7 +145,7 @@ module MCP
response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
end
test "#handle server/discover does not mark the session initialized" do
@ -167,6 +170,26 @@ module MCP
refute response[:result].key?(:instructions)
end
test "#handle server/discover always carries the required cache hints with spec defaults" do
# Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope`
# are REQUIRED on `DiscoverResult`.
server = Server.new(name: "discover_test")
result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result]
assert_equal 0, result[:ttlMs]
assert_equal "private", result[:cacheScope]
end
test "#handle server/discover reuses the configured SEP-2549 cache hints" do
server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public")
result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result]
assert_equal 3_600_000, result[:ttlMs]
assert_equal "public", result[:cacheScope]
end
test "#define_custom_method raises for server/discover" do
assert_raises(Server::MethodAlreadyDefinedError) do
@server.define_custom_method(method_name: "server/discover") { {} }