Merge pull request #480 from koic/discover_finalize_and_client

Finalize `server/discover` and Add Client Modern Lifecycle Support per SEP-2575
This commit is contained in:
Koichi ITO 2026-08-08 15:04:54 +09:00 • committed by GitHub
commit 4ed461c72f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 ### Supported Methods
- `initialize` - Initializes the protocol and returns server capabilities - `initialize` - Initializes the protocol and returns server capabilities
- `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`, - `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the modern `supportedVersions`,
and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id` `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 - `ping` - Simple health check
- `logging/setLevel` - Configures the minimum log level for the server - `logging/setLevel` - Configures the minimum log level for the server
- `tools/list` - Lists all registered tools and their schemas - `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. Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
Authorization is handled by the transport layer. 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 ## 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: 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 # free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
# and a failing exit status. # and a failing exit status.
begin 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.connect(
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
capabilities: capabilities, capabilities: capabilities,
mode: :legacy,
) )
case scenario case scenario

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "client/elicitation" require_relative "client/elicitation"
require_relative "client/modern_envelope"
require_relative "client/oauth" require_relative "client/oauth"
require_relative "client/stdio" require_relative "client/stdio"
require_relative "client/http" require_relative "client/http"
@ -79,6 +80,18 @@ module MCP
end end
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. # Initializes a new MCP::Client instance.
# #
# @param transport [Object] The transport object to use for communication with the server. # @param transport [Object] The transport object to use for communication with the server.
@ -95,14 +108,43 @@ module MCP
# So keeping it public # So keeping it public
attr_reader :transport attr_reader :transport
# The server's `InitializeResult` (protocol version, capabilities, server info, # The raw handshake result exactly as the server returned it, so its shape depends on the connection's era (SEP-2575):
# instructions), as reported by the transport after a successful `connect`. # after the legacy handshake it is an `InitializeResult` (`protocolVersion`, top-level `serverInfo`), after modern adoption
# Returns `nil` before `connect`, after `close`, or when the transport does # it is a `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`, `serverInfo` optionally under `_meta`).
# not expose a cached handshake result. # 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 def server_info
transport.server_info if transport.respond_to?(:server_info) transport.server_info if transport.respond_to?(:server_info)
end 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 # Performs the MCP `initialize` handshake by delegating to the transport
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's # (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
# `InitializeResult`. # `InitializeResult`.
@ -115,17 +157,63 @@ module MCP
# @param capabilities [Hash] Capabilities advertised by the client. May include # @param capabilities [Hash] Capabilities advertised by the client. May include
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers, # an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
# e.g. `{ extensions: { "com.example/feature" => {} } }`. # e.g. `{ extensions: { "com.example/feature" => {} } }`.
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport # @param mode [Symbol, nil] Lifecycle selection (SEP-2575). When omitted, transports whose
# does not expose an explicit handshake. # `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 # 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) return unless transport.respond_to?(:connect)
effective_mode = resolve_connect_mode(mode, protocol_version)
if effective_mode == :legacy
transport.connect( transport.connect(
client_info: client_info, client_info: client_info,
protocol_version: protocol_version, protocol_version: protocol_version,
capabilities: capabilities, 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 end
# Returns true once `connect` has completed the handshake on the underlying # Returns true once `connect` has completed the handshake on the underlying
@ -485,6 +573,46 @@ module MCP
private 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 # 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 # the page results. The `seen` set guards against a server that repeats or
# cycles cursors, so the loop always terminates. # cycles cursors, so the loop always terminates.

View file

@ -6,6 +6,7 @@ require_relative "../configuration"
require_relative "../methods" require_relative "../methods"
require_relative "../protocol_deprecations" require_relative "../protocol_deprecations"
require_relative "../version" require_relative "../version"
require_relative "modern_envelope"
module MCP module MCP
class Client class Client
@ -252,6 +253,8 @@ module MCP
@connected = false @connected = false
@server_request_handlers = {} @server_request_handlers = {}
@listener_thread = nil @listener_thread = nil
@modern_client_info = nil
@modern_capabilities = nil
end end
# Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. # 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`. # @return [Hash] The server's `InitializeResult`.
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error # @raise [RequestHandlerError] If the server responds with a JSON-RPC error
# or a malformed result. # 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 # 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? return @server_info if connected?
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
response = send_request(request: { case mode
jsonrpc: JsonRpcHandler::Version::V2_0, when :legacy
id: SecureRandom.uuid, connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
method: MCP::Methods::INITIALIZE, when :modern
params: { connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
protocolVersion: protocol_version, when :auto
capabilities: capabilities, connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
clientInfo: client_info, else
}, raise ArgumentError, "mode must be :legacy, :modern, or :auto"
}) end
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 end
unless response.is_a?(Hash) && response["result"].is_a?(Hash) # Whether the transport operates in the stateless modern lifecycle (SEP-2575):
clear_session # no handshake was performed and every request carries the `_meta` envelope.
raise RequestHandlerError.new( def modern?
"Server initialization failed: missing result in response", !@modern_client_info.nil?
{ 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 end
# Returns true once `connect` has completed the full handshake # 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`. # Returns false before the first handshake and after `close`.
def connected? def connected?
@connected @connected
@ -373,6 +340,18 @@ module MCP
# a cancellation referring to an unknown request id when the cancel POST happens to arrive first. # 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 # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
def send_request(request:) 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"] method = request[:method] || request["method"]
params = request[:params] || request["params"] params = request[:params] || request["params"]
oauth_retried = false oauth_retried = false
@ -535,6 +514,153 @@ module MCP
attr_reader :headers 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 def client
require_faraday! require_faraday!
@client ||= Faraday.new(url) do |faraday| @client ||= Faraday.new(url) do |faraday|
@ -730,6 +856,8 @@ module MCP
@protocol_version = nil @protocol_version = nil
@server_info = nil @server_info = nil
@connected = false @connected = false
@modern_client_info = nil
@modern_capabilities = nil
end end
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests # 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 "../methods"
require_relative "../protocol_deprecations" require_relative "../protocol_deprecations"
require_relative "../version" require_relative "../version"
require_relative "modern_envelope"
module MCP module MCP
class Client class Client
@ -27,6 +28,12 @@ module MCP
# realistic JSON-RPC frame, including base64-embedded images. # realistic JSON-RPC frame, including base64-embedded images.
MAX_LINE_BYTES = 4 * 1024 * 1024 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 attr_reader :command, :args, :env, :server_info
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
@ -50,6 +57,9 @@ module MCP
@started = false @started = false
@initialized = false @initialized = false
@server_info = nil @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 # Serializes writes to `@stdin` so a request line and a notification line emitted from
# different threads (e.g. cancellation) cannot interleave on the wire. # different threads (e.g. cancellation) cannot interleave on the wire.
@write_mutex = Mutex.new @write_mutex = Mutex.new
@ -73,82 +83,47 @@ module MCP
# @return [Hash] The server's `InitializeResult`. # @return [Hash] The server's `InitializeResult`.
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error, # @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
# a malformed result, or an unsupported protocol version. # 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 # 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 @initialized return @server_info if connected?
start unless @started start unless @started
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
init_request = { case mode
jsonrpc: JsonRpcHandler::Version::V2_0, when :legacy
id: SecureRandom.uuid, connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
method: MCP::Methods::INITIALIZE, when :modern
params: { connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
protocolVersion: protocol_version, when :auto
capabilities: capabilities, connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
clientInfo: client_info, else
}, raise ArgumentError, "mode must be :legacy, :modern, or :auto"
} end
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 end
unless response["result"].is_a?(Hash) # Whether the transport operates in the stateless modern lifecycle (SEP-2575):
raise RequestHandlerError.new( # no handshake was performed and every request carries the `_meta` envelope.
"Server initialization failed: missing result in response", def modern?
{ method: MCP::Methods::INITIALIZE }, !@modern_client_info.nil?
error_type: :internal_error,
)
end end
@server_info = response["result"] # The protocol version in use on this connection, independent of its era:
# negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
negotiated_protocol_version = @server_info["protocolVersion"] # Returns `nil` before `connect` and after `close`.
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) def protocol_version
# Per spec, if the client does not support the server's returned protocol version, @modern_protocol_version || (@server_info && @server_info["protocolVersion"])
# 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 end
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) # Returns true once `connect` has completed the handshake or adopted the modern lifecycle.
# Returns false before the handshake and after `close`.
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`.
def connected? def connected?
@initialized @initialized || modern?
end end
# Transports may yield once the request line has been written to `@stdin`. # 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`, # 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. # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
def send_request(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_mutex.synchronize do
write_message(request) write_message(request)
@ -169,7 +153,7 @@ module MCP
# `notifications/cancelled` for an in-flight request. # `notifications/cancelled` for an in-flight request.
def send_notification(notification:) def send_notification(notification:)
start unless @started start unless @started
connect unless @initialized connect unless connected?
@write_mutex.synchronize { write_message(notification) } @write_mutex.synchronize { write_message(notification) }
nil nil
@ -231,10 +215,187 @@ module MCP
@started = false @started = false
@initialized = false @initialized = false
@server_info = nil @server_info = nil
leave_modern_mode
end end
private 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) def write_message(message)
ensure_running! ensure_running!
json = JSON.generate(message) 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. # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format.
LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" 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 = [ REQUIRED_META_KEYS = [
PROTOCOL_VERSION_META_KEY, PROTOCOL_VERSION_META_KEY,
CLIENT_INFO_META_KEY, CLIENT_INFO_META_KEY,

View file

@ -774,20 +774,26 @@ module MCP
end end
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 # 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, # 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 # so clients can probe a server before (or instead of) `initialize`.
# because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints #
# are not included here yet. # `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 # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
def discover(_request) def discover(_request)
{ {
supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
capabilities: capabilities, capabilities: capabilities,
serverInfo: server_info,
instructions: instructions, instructions: instructions,
}.compact _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
}.compact.merge(ttlMs: ttl_ms || 0, cacheScope: cache_scope || "private")
end end
def configure_logging_level(request, session: nil) def configure_logging_level(request, session: nil)

View file

@ -1844,6 +1844,109 @@ module MCP
refute_predicate(client, :connected?) refute_predicate(client, :connected?)
end 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 def test_reconnect_after_close
stub_initialize stub_initialize
stub_notification stub_notification
@ -1949,6 +2052,24 @@ module MCP
.to_return(status: 202, body: "") .to_return(status: 202, body: "")
end 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) def stub_request(method, url)
WebMock.stub_request(method, url) WebMock.stub_request(method, url)
end end

View file

@ -1414,6 +1414,198 @@ module MCP
[transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]] [transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]]
end 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 def mock_wait_thread
thread = mock("wait_thread") thread = mock("wait_thread")
thread.stubs(:alive?).returns(true) thread.stubs(:alive?).returns(true)

View file

@ -85,6 +85,218 @@ module MCP
assert_includes(error.message, "does not support server-to-client requests") assert_includes(error.message, "does not support server-to-client requests")
end 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 def test_connected_delegates_to_transport_when_supported
transport = mock transport = mock
transport.expects(:connected?).returns(true) transport.expects(:connected?).returns(true)

View file

@ -583,7 +583,7 @@ module MCP
modern_ping_request(id: 3), 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 :modern, session_era
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code) assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported) 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] assert_equal 200, response[0]
body = JSON.parse(response[2][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") refute response[1].key?("mcp-session-id")
end end
@ -678,7 +678,7 @@ module MCP
assert_equal 200, response[0] assert_equal 200, response[0]
body = JSON.parse(response[2][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 end
test "rejects duplicate SSE connection with 409" do test "rejects duplicate SSE connection with 409" do
@ -5426,7 +5426,7 @@ module MCP
assert_equal 200, response[0] assert_equal 200, response[0]
refute response[1].key?("mcp-session-id") refute response[1].key?("mcp-session-id")
body = JSON.parse(response[2][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 end
test "legacy POST rejects a modern envelope body without the modern header as -32020" do 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 }) response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
result = response[:result] 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.capabilities, result[:capabilities]
assert_equal @server_name, result.dig(:serverInfo, :name) # Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp,
assert_equal "1.2.3", result.dig(:serverInfo, :version) # 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] assert_equal "Optional instructions for the client", result[:instructions]
end end
@ -142,7 +145,7 @@ module MCP
response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) 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 end
test "#handle server/discover does not mark the session initialized" do test "#handle server/discover does not mark the session initialized" do
@ -167,6 +170,26 @@ module MCP
refute response[:result].key?(:instructions) refute response[:result].key?(:instructions)
end 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 test "#define_custom_method raises for server/discover" do
assert_raises(Server::MethodAlreadyDefinedError) do assert_raises(Server::MethodAlreadyDefinedError) do
@server.define_custom_method(method_name: "server/discover") { {} } @server.define_custom_method(method_name: "server/discover") { {} }