Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575
## Motivation and Context Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic. Server side, `Server#discover` reaches its spec-final shape: - `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: legacy versions are negotiated via `initialize`, not selected from discovery. This also closes an interop hazard of the interim response: auto-negotiating TS and Python clients treat any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`, so advertising only legacy versions made them fail outright. - The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise. - The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002 (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`. Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default: - `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`). It works before `connect`; the stdio transport's connect guard exempts `server/discover` and spawns the subprocess on demand. - `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:` (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover` and falls back to the legacy handshake on any discovery failure, including a successful discovery without a mutually supported modern version (rollout tolerance for servers that answer discovery while only serving legacy versions). Custom transports whose `connect` does not declare `mode:` keep receiving the historical legacy call shape, detected by keyword introspection; a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError` instead of silently downgrading. - The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`, instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version, the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here as `mode: :modern` with `protocol_version: "2026-07-28"`. - An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe, so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle. `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools and when using server-initiated requests, which exist only on the legacy lifecycle. - Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference: `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one), `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta` `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover. - The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK) when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback instead of blocking `connect` forever. Regular requests keep the unbounded default. - In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize` is never sent. - The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake and its scenario servers do not expect a preceding probe. Resolves #389. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values); the existing discover assertions across `server_test.rb` and both transport test files were updated to the modern-only `supportedVersions`. New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError` on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`, the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`, `ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection, and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence. New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks, and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`, modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout default applying only while probing, and the fallback when a server never answers the probe. ## Breaking Changes `server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`); this method has not shipped in a gem release yet, so no released behavior changes. On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation `protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
This commit is contained in:
parent
d51d4a0f91
commit
d0d7308a89
14 changed files with 1203 additions and 151 deletions
45
README.md
45
README.md
|
|
@ -46,8 +46,14 @@ It implements the Model Context Protocol specification, handling model context r
|
|||
### Supported Methods
|
||||
|
||||
- `initialize` - Initializes the protocol and returns server capabilities
|
||||
- `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`,
|
||||
and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id`
|
||||
- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the modern `supportedVersions`,
|
||||
`capabilities`, `instructions`, the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional
|
||||
`io.modelcontextprotocol/serverInfo` stamp in the result `_meta`, and responds before `initialize`
|
||||
and without an `Mcp-Session-Id`. The server also serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope
|
||||
(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request,
|
||||
and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates
|
||||
the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips
|
||||
the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result
|
||||
- `ping` - Simple health check
|
||||
- `logging/setLevel` - Configures the minimum log level for the server
|
||||
- `tools/list` - Lists all registered tools and their schemas
|
||||
|
|
@ -2376,6 +2382,41 @@ This class supports:
|
|||
Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
|
||||
Authorization is handled by the transport layer.
|
||||
|
||||
### Lifecycle Negotiation (SEP-2575)
|
||||
|
||||
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
|
||||
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes `server/discover` first and adopts
|
||||
the stateless modern lifecycle (MCP 2026-07-28) when the server serves it, falling back to
|
||||
the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare
|
||||
a `mode:` keyword always receive the classic call shape, unchanged.
|
||||
|
||||
```ruby
|
||||
client.connect # negotiate automatically (default)
|
||||
client.connect(mode: :legacy) # force the classic initialize handshake
|
||||
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
|
||||
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe
|
||||
```
|
||||
|
||||
Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process)
|
||||
and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on
|
||||
the legacy lifecycle.
|
||||
|
||||
Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result,
|
||||
their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`,
|
||||
top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`)
|
||||
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
|
||||
|
||||
```ruby
|
||||
client.protocol_version # negotiated or adopted version, either lifecycle
|
||||
client.server_capabilities # capabilities Hash, either lifecycle
|
||||
client.instructions # instructions text, either lifecycle
|
||||
client.server_implementation # server name/version; nil when a modern server does not identify itself
|
||||
```
|
||||
|
||||
Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded,
|
||||
the server now serves the modern lifecycle and the automatic negotiation adopted it.
|
||||
Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
|
||||
|
||||
## Transport Layer Interface
|
||||
|
||||
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:
|
||||
|
|
|
|||
|
|
@ -171,9 +171,12 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation
|
|||
# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
|
||||
# and a failing exit status.
|
||||
begin
|
||||
# The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle:
|
||||
# the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect.
|
||||
client.connect(
|
||||
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
||||
capabilities: capabilities,
|
||||
mode: :legacy,
|
||||
)
|
||||
|
||||
case scenario
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
lib/mcp/client/modern_envelope.rb
Normal file
32
lib/mcp/client/modern_envelope.rb
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -767,20 +767,26 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery.
|
||||
# Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery.
|
||||
# Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
|
||||
# the session initialized, and responds regardless of capability declarations or initialization state,
|
||||
# so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered
|
||||
# because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints
|
||||
# are not included here yet.
|
||||
# so clients can probe a server before (or instead of) `initialize`.
|
||||
#
|
||||
# `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
|
||||
# legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope`
|
||||
# cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results),
|
||||
# so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only)
|
||||
# fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides
|
||||
# in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized
|
||||
# spec (PR #3002), unfiltered because discovery happens before version negotiation.
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
||||
def discover(_request)
|
||||
{
|
||||
supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
|
||||
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
||||
capabilities: capabilities,
|
||||
serverInfo: server_info,
|
||||
instructions: instructions,
|
||||
}.compact
|
||||
_meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
|
||||
}.compact.merge(ttlMs: ttl_ms || 0, cacheScope: cache_scope || "private")
|
||||
end
|
||||
|
||||
def configure_logging_level(request, session: nil)
|
||||
|
|
|
|||
|
|
@ -1844,6 +1844,109 @@ module MCP
|
|||
refute_predicate(client, :connected?)
|
||||
end
|
||||
|
||||
def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle
|
||||
discover_stub = stub_discover
|
||||
|
||||
result = client.connect(mode: :modern)
|
||||
|
||||
assert_requested(discover_stub)
|
||||
assert_predicate(client, :connected?)
|
||||
assert_predicate(client, :modern?)
|
||||
assert_equal(["2026-07-28"], result["supportedVersions"])
|
||||
assert_equal(result, client.server_info)
|
||||
assert_equal("2026-07-28", client.protocol_version)
|
||||
end
|
||||
|
||||
def test_modern_requests_carry_the_envelope_and_matching_header
|
||||
stub_discover
|
||||
client.connect(
|
||||
mode: :modern,
|
||||
client_info: { name: "my-app", version: "9.9" },
|
||||
capabilities: { elicitation: {} },
|
||||
)
|
||||
|
||||
request_stub = stub_request(:post, url).with do |req|
|
||||
body = JSON.parse(req.body)
|
||||
meta = body.dig("params", "_meta")
|
||||
body["method"] == "tools/list" &&
|
||||
req.headers["Mcp-Protocol-Version"] == "2026-07-28" &&
|
||||
meta == {
|
||||
"io.modelcontextprotocol/protocolVersion" => "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" },
|
||||
"io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} },
|
||||
}
|
||||
end.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { result: { tools: [] } }.to_json,
|
||||
)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
||||
|
||||
assert_requested(request_stub)
|
||||
end
|
||||
|
||||
def test_connect_modern_fails_without_a_mutual_modern_version
|
||||
stub_request(:post, url).with do |req|
|
||||
JSON.parse(req.body)["method"] == "server/discover"
|
||||
end.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
|
||||
)
|
||||
|
||||
error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) }
|
||||
|
||||
assert_includes(error.message, "no mutually supported modern protocol version")
|
||||
refute_predicate(client, :connected?)
|
||||
refute_predicate(client, :modern?)
|
||||
end
|
||||
|
||||
def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors
|
||||
stub_request(:post, url).with do |req|
|
||||
JSON.parse(req.body)["method"] == "server/discover"
|
||||
end.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { error: { code: -32601, message: "Method not found" } }.to_json,
|
||||
)
|
||||
init_stub = stub_initialize
|
||||
notification_stub = stub_notification
|
||||
|
||||
result = client.connect(mode: :auto)
|
||||
|
||||
assert_requested(init_stub)
|
||||
assert_requested(notification_stub)
|
||||
refute_predicate(client, :modern?)
|
||||
assert_predicate(client, :connected?)
|
||||
assert_equal("2025-11-25", result["protocolVersion"])
|
||||
end
|
||||
|
||||
def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version
|
||||
# Rollout tolerance: a server may answer discovery while only serving legacy versions;
|
||||
# auto mode falls back to the legacy handshake.
|
||||
stub_request(:post, url).with do |req|
|
||||
JSON.parse(req.body)["method"] == "server/discover"
|
||||
end.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
|
||||
)
|
||||
init_stub = stub_initialize
|
||||
stub_notification
|
||||
|
||||
client.connect(mode: :auto)
|
||||
|
||||
assert_requested(init_stub)
|
||||
refute_predicate(client, :modern?)
|
||||
assert_predicate(client, :connected?)
|
||||
end
|
||||
|
||||
def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode
|
||||
assert_raises(ArgumentError) { client.connect(mode: :bogus) }
|
||||
assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") }
|
||||
end
|
||||
|
||||
def test_reconnect_after_close
|
||||
stub_initialize
|
||||
stub_notification
|
||||
|
|
@ -1949,6 +2052,24 @@ module MCP
|
|||
.to_return(status: 202, body: "")
|
||||
end
|
||||
|
||||
def stub_discover
|
||||
stub_request(:post, url).with do |req|
|
||||
JSON.parse(req.body)["method"] == "server/discover"
|
||||
end.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: {
|
||||
result: {
|
||||
supportedVersions: ["2026-07-28"],
|
||||
capabilities: { tools: {} },
|
||||
_meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } },
|
||||
ttlMs: 0,
|
||||
cacheScope: "private",
|
||||
},
|
||||
}.to_json,
|
||||
)
|
||||
end
|
||||
|
||||
def stub_request(method, url)
|
||||
WebMock.stub_request(method, url)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1414,6 +1414,198 @@ module MCP
|
|||
[transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]]
|
||||
end
|
||||
|
||||
def test_send_request_allows_server_discover_before_connect
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||
|
||||
server_thread = Thread.new do
|
||||
discover_request = JSON.parse(stdin_read.gets)
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: discover_request["id"],
|
||||
result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" },
|
||||
}
|
||||
stdout_write.puts(JSON.generate(response))
|
||||
stdout_write.flush
|
||||
end
|
||||
|
||||
# No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery.
|
||||
response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" })
|
||||
|
||||
assert_equal(["2026-07-28"], response.dig("result", "supportedVersions"))
|
||||
refute_predicate(transport, :connected?)
|
||||
ensure
|
||||
server_thread&.join
|
||||
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
||||
io.close unless io.closed?
|
||||
end
|
||||
end
|
||||
|
||||
def test_connect_modern_probes_discover_and_stamps_requests
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||
|
||||
received = []
|
||||
server_thread = Thread.new do
|
||||
discover_request = JSON.parse(stdin_read.gets)
|
||||
received << discover_request
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: discover_request["id"],
|
||||
result: {
|
||||
supportedVersions: ["2026-07-28"],
|
||||
capabilities: {},
|
||||
serverInfo: { name: "test-server", version: "1.0" },
|
||||
ttlMs: 0,
|
||||
cacheScope: "private",
|
||||
},
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
tools_request = JSON.parse(stdin_read.gets)
|
||||
received << tools_request
|
||||
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } }))
|
||||
stdout_write.flush
|
||||
end
|
||||
|
||||
result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" })
|
||||
response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" })
|
||||
|
||||
assert_equal(["2026-07-28"], result["supportedVersions"])
|
||||
assert_predicate(transport, :modern?)
|
||||
assert_predicate(transport, :connected?)
|
||||
assert_equal("2026-07-28", transport.protocol_version)
|
||||
assert_equal("t1", response["id"])
|
||||
|
||||
# No `initialize` was sent; both frames carry the SEP-2575 envelope.
|
||||
assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] })
|
||||
triple = received[1].dig("params", "_meta")
|
||||
assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"])
|
||||
assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"])
|
||||
assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"])
|
||||
ensure
|
||||
server_thread&.join
|
||||
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
||||
io.close unless io.closed?
|
||||
end
|
||||
end
|
||||
|
||||
def test_connect_auto_falls_back_to_the_legacy_handshake
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||
|
||||
server_thread = Thread.new do
|
||||
discover_request = JSON.parse(stdin_read.gets)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: discover_request["id"],
|
||||
error: { code: -32601, message: "Method not found" },
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
init_request = JSON.parse(stdin_read.gets)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: init_request["id"],
|
||||
result: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "test-server", version: "1.0" },
|
||||
},
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
# `notifications/initialized`
|
||||
stdin_read.gets
|
||||
end
|
||||
|
||||
result = transport.connect(mode: :auto)
|
||||
|
||||
assert_equal("2025-11-25", result["protocolVersion"])
|
||||
assert_equal("2025-11-25", transport.protocol_version)
|
||||
refute_predicate(transport, :modern?)
|
||||
assert_predicate(transport, :connected?)
|
||||
ensure
|
||||
server_thread&.join
|
||||
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
||||
io.close unless io.closed?
|
||||
end
|
||||
end
|
||||
|
||||
def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||
transport.stubs(:start)
|
||||
|
||||
observed_timeout = nil
|
||||
transport.define_singleton_method(:probe_discover) do
|
||||
observed_timeout = @read_timeout
|
||||
raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error)
|
||||
end
|
||||
transport.define_singleton_method(:connect_legacy) do |**|
|
||||
{ "protocolVersion" => "2025-11-25" }
|
||||
end
|
||||
|
||||
result = transport.connect(mode: :auto)
|
||||
|
||||
assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout)
|
||||
assert_nil(transport.instance_variable_get(:@read_timeout))
|
||||
assert_equal("2025-11-25", result["protocolVersion"])
|
||||
end
|
||||
|
||||
def test_connect_auto_falls_back_when_the_server_never_answers_the_probe
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
# A configured `read_timeout` also bounds the probe; the server below reads
|
||||
# the `server/discover` request and never responds, which must count as
|
||||
# legacy evidence instead of blocking `connect` forever.
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05)
|
||||
|
||||
server_thread = Thread.new do
|
||||
# Silently swallow the probe.
|
||||
stdin_read.gets
|
||||
|
||||
init_request = JSON.parse(stdin_read.gets)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: init_request["id"],
|
||||
result: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "test-server", version: "1.0" },
|
||||
},
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
# `notifications/initialized`
|
||||
stdin_read.gets
|
||||
end
|
||||
|
||||
result = transport.connect(mode: :auto)
|
||||
|
||||
assert_equal("2025-11-25", result["protocolVersion"])
|
||||
refute_predicate(transport, :modern?)
|
||||
ensure
|
||||
server_thread&.join
|
||||
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
||||
io.close unless io.closed?
|
||||
end
|
||||
end
|
||||
|
||||
def mock_wait_thread
|
||||
thread = mock("wait_thread")
|
||||
thread.stubs(:alive?).returns(true)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,218 @@ module MCP
|
|||
assert_includes(error.message, "does not support server-to-client requests")
|
||||
end
|
||||
|
||||
# A transport that declares `mode:` on `connect`, recording what it received.
|
||||
# The `:not_forwarded` sentinel distinguishes "Client did not forward mode" from
|
||||
# "Client forwarded mode: :legacy".
|
||||
class ModeAwareTransport
|
||||
attr_reader :received
|
||||
|
||||
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :not_forwarded)
|
||||
@received = {
|
||||
client_info: client_info,
|
||||
protocol_version: protocol_version,
|
||||
capabilities: capabilities,
|
||||
mode: mode,
|
||||
}
|
||||
{ "supportedVersions" => ["2026-07-28"] }
|
||||
end
|
||||
end
|
||||
|
||||
def test_connect_defaults_to_auto_on_transports_declaring_mode
|
||||
transport = ModeAwareTransport.new
|
||||
|
||||
Client.new(transport: transport).connect
|
||||
|
||||
assert_equal(:auto, transport.received[:mode])
|
||||
end
|
||||
|
||||
def test_connect_keeps_the_legacy_call_shape_for_transports_without_mode
|
||||
# Custom transports whose `connect` does not declare `mode:` keep receiving
|
||||
# the historical call shape, so the `:auto` default cannot break them.
|
||||
transport = mock
|
||||
transport.expects(:connect).with(
|
||||
client_info: nil, protocol_version: nil, capabilities: {},
|
||||
).returns({ "protocolVersion" => "2025-11-25" }).once
|
||||
|
||||
Client.new(transport: transport).connect
|
||||
end
|
||||
|
||||
def test_connect_routes_explicit_legacy_through_the_legacy_call_shape
|
||||
transport = ModeAwareTransport.new
|
||||
|
||||
Client.new(transport: transport).connect(mode: :legacy)
|
||||
|
||||
assert_equal(:not_forwarded, transport.received[:mode])
|
||||
end
|
||||
|
||||
def test_connect_pins_legacy_for_an_explicit_stable_protocol_version
|
||||
# An explicitly requested legacy-generation version must never be overridden
|
||||
# by the default negotiation adopting the modern lifecycle.
|
||||
transport = ModeAwareTransport.new
|
||||
|
||||
Client.new(transport: transport).connect(protocol_version: "2025-11-25")
|
||||
|
||||
assert_equal(:not_forwarded, transport.received[:mode])
|
||||
assert_equal("2025-11-25", transport.received[:protocol_version])
|
||||
end
|
||||
|
||||
def test_connect_negotiates_automatically_for_an_explicit_modern_protocol_version
|
||||
transport = ModeAwareTransport.new
|
||||
|
||||
Client.new(transport: transport).connect(protocol_version: "2026-07-28")
|
||||
|
||||
assert_equal(:auto, transport.received[:mode])
|
||||
end
|
||||
|
||||
def test_connect_raises_for_explicit_non_legacy_mode_on_a_transport_without_mode
|
||||
transport = mock
|
||||
transport.expects(:connect).never
|
||||
|
||||
error = assert_raises(ArgumentError) do
|
||||
Client.new(transport: transport).connect(mode: :modern)
|
||||
end
|
||||
|
||||
assert_includes(error.message, "does not support mode:")
|
||||
end
|
||||
|
||||
def test_connect_rejects_unknown_modes
|
||||
transport = ModeAwareTransport.new
|
||||
|
||||
assert_raises(ArgumentError) do
|
||||
Client.new(transport: transport).connect(mode: :bogus)
|
||||
end
|
||||
end
|
||||
|
||||
def test_discover_returns_a_discover_result
|
||||
transport = mock
|
||||
transport.expects(:send_request).with do |args|
|
||||
args[:request][:method] == "server/discover"
|
||||
end.returns({
|
||||
"result" => {
|
||||
"supportedVersions" => ["2026-07-28"],
|
||||
"capabilities" => { "tools" => {} },
|
||||
"_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "test-server", "version" => "1.0" } },
|
||||
"instructions" => "Optional instructions",
|
||||
"ttlMs" => 3_600_000,
|
||||
"cacheScope" => "public",
|
||||
},
|
||||
}).once
|
||||
|
||||
result = Client.new(transport: transport).discover
|
||||
|
||||
assert_equal(["2026-07-28"], result.supported_versions)
|
||||
assert_equal({ "tools" => {} }, result.capabilities)
|
||||
assert_equal({ "name" => "test-server", "version" => "1.0" }, result.server_info)
|
||||
assert_equal("Optional instructions", result.instructions)
|
||||
assert_equal(3_600_000, result.ttl_ms)
|
||||
assert_equal("public", result.cache_scope)
|
||||
end
|
||||
|
||||
def test_discover_maps_a_top_level_server_info_as_a_fallback
|
||||
# Servers built against the frozen SEP text still stamp `serverInfo` at the top level;
|
||||
# the finalized spec moved it into the result `_meta`.
|
||||
transport = mock
|
||||
transport.expects(:send_request).returns({
|
||||
"result" => {
|
||||
"supportedVersions" => ["2026-07-28"],
|
||||
"serverInfo" => { "name" => "sep-era-server", "version" => "1.0" },
|
||||
},
|
||||
}).once
|
||||
|
||||
result = Client.new(transport: transport).discover
|
||||
|
||||
assert_equal({ "name" => "sep-era-server", "version" => "1.0" }, result.server_info)
|
||||
end
|
||||
|
||||
def test_discover_raises_validation_error_on_missing_result
|
||||
transport = mock
|
||||
transport.expects(:send_request).returns({}).once
|
||||
|
||||
assert_raises(Client::ValidationError) do
|
||||
Client.new(transport: transport).discover
|
||||
end
|
||||
end
|
||||
|
||||
def test_discover_raises_server_error_on_jsonrpc_error
|
||||
transport = mock
|
||||
transport.expects(:send_request).returns({
|
||||
"error" => { "code" => -32601, "message" => "Method not found" },
|
||||
}).once
|
||||
|
||||
error = assert_raises(Client::ServerError) do
|
||||
Client.new(transport: transport).discover
|
||||
end
|
||||
|
||||
assert_equal(-32601, error.code)
|
||||
end
|
||||
|
||||
def test_era_independent_readers_on_a_legacy_result
|
||||
transport = mock
|
||||
transport.stubs(:server_info).returns({
|
||||
"protocolVersion" => "2025-11-25",
|
||||
"capabilities" => { "tools" => {} },
|
||||
"serverInfo" => { "name" => "legacy-server", "version" => "1.0" },
|
||||
"instructions" => "Be helpful",
|
||||
})
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_equal("2025-11-25", client.protocol_version)
|
||||
assert_equal({ "tools" => {} }, client.server_capabilities)
|
||||
assert_equal("Be helpful", client.instructions)
|
||||
assert_equal({ "name" => "legacy-server", "version" => "1.0" }, client.server_implementation)
|
||||
end
|
||||
|
||||
def test_era_independent_readers_on_a_modern_result
|
||||
# A modern `DiscoverResult` has no `protocolVersion`; the adopted version comes
|
||||
# from the transport. `serverInfo` sits in the result `_meta` per the final spec.
|
||||
transport = mock
|
||||
transport.stubs(:protocol_version).returns("2026-07-28")
|
||||
transport.stubs(:server_info).returns({
|
||||
"supportedVersions" => ["2026-07-28"],
|
||||
"capabilities" => { "tools" => {} },
|
||||
"instructions" => "Be helpful",
|
||||
"_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "modern-server", "version" => "2.0" } },
|
||||
"ttlMs" => 0,
|
||||
"cacheScope" => "private",
|
||||
})
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_equal("2026-07-28", client.protocol_version)
|
||||
assert_equal({ "tools" => {} }, client.server_capabilities)
|
||||
assert_equal("Be helpful", client.instructions)
|
||||
assert_equal({ "name" => "modern-server", "version" => "2.0" }, client.server_implementation)
|
||||
end
|
||||
|
||||
def test_server_implementation_falls_back_to_the_top_level_and_tolerates_absence
|
||||
transport = mock
|
||||
transport.stubs(:server_info).returns({
|
||||
"supportedVersions" => ["2026-07-28"],
|
||||
"serverInfo" => { "name" => "top-level", "version" => "1.0" },
|
||||
})
|
||||
|
||||
assert_equal({ "name" => "top-level", "version" => "1.0" }, Client.new(transport: transport).server_implementation)
|
||||
|
||||
anonymous = mock
|
||||
anonymous.stubs(:server_info).returns({ "supportedVersions" => ["2026-07-28"] })
|
||||
|
||||
assert_nil(Client.new(transport: anonymous).server_implementation)
|
||||
end
|
||||
|
||||
def test_era_independent_readers_return_nil_before_connect
|
||||
transport = mock
|
||||
transport.stubs(:respond_to?).with(:server_info).returns(false)
|
||||
transport.stubs(:respond_to?).with(:protocol_version).returns(false)
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_nil(client.protocol_version)
|
||||
assert_nil(client.server_capabilities)
|
||||
assert_nil(client.instructions)
|
||||
assert_nil(client.server_implementation)
|
||||
end
|
||||
|
||||
def test_connected_delegates_to_transport_when_supported
|
||||
transport = mock
|
||||
transport.expects(:connected?).returns(true)
|
||||
|
|
|
|||
|
|
@ -583,7 +583,7 @@ module MCP
|
|||
modern_ping_request(id: 3),
|
||||
])
|
||||
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
|
||||
assert_equal :modern, session_era
|
||||
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)
|
||||
|
|
|
|||
|
|
@ -660,7 +660,7 @@ module MCP
|
|||
|
||||
assert_equal 200, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
refute response[1].key?("mcp-session-id")
|
||||
end
|
||||
|
||||
|
|
@ -678,7 +678,7 @@ module MCP
|
|||
|
||||
assert_equal 200, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
end
|
||||
|
||||
test "rejects duplicate SSE connection with 409" do
|
||||
|
|
@ -5426,7 +5426,7 @@ module MCP
|
|||
assert_equal 200, response[0]
|
||||
refute response[1].key?("mcp-session-id")
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
end
|
||||
|
||||
test "legacy POST rejects a modern envelope body without the modern header as -32020" do
|
||||
|
|
|
|||
|
|
@ -129,10 +129,13 @@ module MCP
|
|||
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
|
||||
result = response[:result]
|
||||
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, result[:supportedVersions]
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions]
|
||||
assert_equal @server.capabilities, result[:capabilities]
|
||||
assert_equal @server_name, result.dig(:serverInfo, :name)
|
||||
assert_equal "1.2.3", result.dig(:serverInfo, :version)
|
||||
# Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp,
|
||||
# not a top-level `serverInfo` field.
|
||||
assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name)
|
||||
assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version)
|
||||
refute result.key?(:serverInfo)
|
||||
assert_equal "Optional instructions for the client", result[:instructions]
|
||||
end
|
||||
|
||||
|
|
@ -142,7 +145,7 @@ module MCP
|
|||
|
||||
response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
|
||||
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
|
||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
|
||||
end
|
||||
|
||||
test "#handle server/discover does not mark the session initialized" do
|
||||
|
|
@ -167,6 +170,26 @@ module MCP
|
|||
refute response[:result].key?(:instructions)
|
||||
end
|
||||
|
||||
test "#handle server/discover always carries the required cache hints with spec defaults" do
|
||||
# Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope`
|
||||
# are REQUIRED on `DiscoverResult`.
|
||||
server = Server.new(name: "discover_test")
|
||||
|
||||
result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result]
|
||||
|
||||
assert_equal 0, result[:ttlMs]
|
||||
assert_equal "private", result[:cacheScope]
|
||||
end
|
||||
|
||||
test "#handle server/discover reuses the configured SEP-2549 cache hints" do
|
||||
server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public")
|
||||
|
||||
result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result]
|
||||
|
||||
assert_equal 3_600_000, result[:ttlMs]
|
||||
assert_equal "public", result[:cacheScope]
|
||||
end
|
||||
|
||||
test "#define_custom_method raises for server/discover" do
|
||||
assert_raises(Server::MethodAlreadyDefinedError) do
|
||||
@server.define_custom_method(method_name: "server/discover") { {} }
|
||||
|
|
|
|||
Loading…
Reference in a new issue