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
6 changed files with 1203 additions and 151 deletions

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

@ -774,20 +774,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)