Merge pull request #516 from koic/counter_offer_legacy_version_on_initialize
Stop negotiating modern protocol versions through the `initialize` handshake
This commit is contained in:
commit
a62e56ef48
20 changed files with 472 additions and 207 deletions
|
|
@ -585,8 +585,7 @@ configuration = MCP::Configuration.new(protocol_version: "2024-11-05")
|
||||||
MCP::Server.new(name: "test_server", configuration: configuration)
|
MCP::Server.new(name: "test_server", configuration: configuration)
|
||||||
```
|
```
|
||||||
|
|
||||||
If no protocol version is specified, the latest stable version will be applied by default.
|
If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default.
|
||||||
The latest stable version includes new features from the [draft version](https://modelcontextprotocol.io/specification/draft).
|
|
||||||
|
|
||||||
This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`:
|
This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`:
|
||||||
|
|
||||||
|
|
@ -596,6 +595,11 @@ MCP::Configuration.new(protocol_version: nil)
|
||||||
|
|
||||||
If an invalid `protocol_version` value is set, an `ArgumentError` is raised.
|
If an invalid `protocol_version` value is set, an `ArgumentError` is raised.
|
||||||
|
|
||||||
|
The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model,
|
||||||
|
`2026-07-28` carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises `ArgumentError`;
|
||||||
|
a client asking `initialize` for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs.
|
||||||
|
Clients reach `2026-07-28` through `server/discover` and the per-request `_meta` envelope, which the bundled transports serve alongside the handshake with no configuration needed.
|
||||||
|
|
||||||
Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set.
|
Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set.
|
||||||
|
|
||||||
### Exception Reporting
|
### Exception Reporting
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ Call `MCP::Client#connect` to perform the MCP [initialization handshake](https:/
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
client.connect
|
client.connect
|
||||||
# => { "protocolVersion" => "2026-07-28", "capabilities" => {...}, "serverInfo" => {...} }
|
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||||
|
|
||||||
client.connected? # => true
|
client.connected? # => true
|
||||||
client.server_info # => cached InitializeResult
|
client.server_info # => cached InitializeResult
|
||||||
|
|
@ -99,7 +99,7 @@ After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` heade
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
http_transport.session_id # => "abc123..."
|
http_transport.session_id # => "abc123..."
|
||||||
http_transport.protocol_version # => "2026-07-28"
|
http_transport.protocol_version # => "2025-11-25"
|
||||||
```
|
```
|
||||||
|
|
||||||
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
|
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
|
||||||
|
|
|
||||||
|
|
@ -327,8 +327,9 @@ module MCP
|
||||||
#
|
#
|
||||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||||
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
||||||
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
||||||
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
|
||||||
|
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
|
||||||
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
||||||
# @return [Hash] The server's `InitializeResult`.
|
# @return [Hash] The server's `InitializeResult`.
|
||||||
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
||||||
|
|
@ -341,6 +342,9 @@ module MCP
|
||||||
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
|
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
|
||||||
return @server_info if connected?
|
return @server_info if connected?
|
||||||
|
|
||||||
|
# Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake.
|
||||||
|
MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
|
||||||
|
|
||||||
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
||||||
|
|
||||||
case mode
|
case mode
|
||||||
|
|
@ -559,7 +563,7 @@ module MCP
|
||||||
attr_reader :headers
|
attr_reader :headers
|
||||||
|
|
||||||
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
||||||
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
||||||
|
|
||||||
response = send_request(request: {
|
response = send_request(request: {
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||||
|
|
@ -593,7 +597,10 @@ module MCP
|
||||||
|
|
||||||
@server_info = response["result"]
|
@server_info = response["result"]
|
||||||
negotiated_protocol_version = @server_info["protocolVersion"]
|
negotiated_protocol_version = @server_info["protocolVersion"]
|
||||||
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
# A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake
|
||||||
|
# settles on a legacy version by definition, and the TypeScript and Python clients refuse
|
||||||
|
# a modern counter-offer the same way.
|
||||||
|
unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
||||||
clear_session
|
clear_session
|
||||||
raise RequestHandlerError.new(
|
raise RequestHandlerError.new(
|
||||||
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
||||||
|
|
@ -602,8 +609,6 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
send_request(request: {
|
send_request(request: {
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||||
|
|
@ -647,6 +652,10 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
|
||||||
|
# so the warning lives here now that the handshake cannot land on one.
|
||||||
|
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
|
||||||
|
|
||||||
@server_info = result
|
@server_info = result
|
||||||
@connected = true
|
@connected = true
|
||||||
@server_info
|
@server_info
|
||||||
|
|
@ -658,8 +667,13 @@ module MCP
|
||||||
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
||||||
# only serving legacy versions.
|
# only serving legacy versions.
|
||||||
def connect_auto(client_info:, protocol_version:, capabilities:)
|
def connect_auto(client_info:, protocol_version:, capabilities:)
|
||||||
connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities)
|
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
|
||||||
|
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
|
||||||
rescue RequestHandlerError
|
rescue RequestHandlerError
|
||||||
|
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
|
||||||
|
# so the probe's failure is the real answer and propagates.
|
||||||
|
raise if modern_pin
|
||||||
|
|
||||||
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,9 @@ module MCP
|
||||||
#
|
#
|
||||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||||
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
||||||
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
||||||
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
|
||||||
|
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
|
||||||
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
||||||
# @return [Hash] The server's `InitializeResult`.
|
# @return [Hash] The server's `InitializeResult`.
|
||||||
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
||||||
|
|
@ -91,6 +92,9 @@ module MCP
|
||||||
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
|
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
|
||||||
return @server_info if connected?
|
return @server_info if connected?
|
||||||
|
|
||||||
|
# Validated before `start` so a pure argument error never spawns the server process.
|
||||||
|
MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
|
||||||
|
|
||||||
start unless @started
|
start unless @started
|
||||||
|
|
||||||
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
||||||
|
|
@ -221,7 +225,7 @@ module MCP
|
||||||
private
|
private
|
||||||
|
|
||||||
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
||||||
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
||||||
|
|
||||||
init_request = {
|
init_request = {
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||||
|
|
@ -257,9 +261,11 @@ module MCP
|
||||||
@server_info = response["result"]
|
@server_info = response["result"]
|
||||||
|
|
||||||
negotiated_protocol_version = @server_info["protocolVersion"]
|
negotiated_protocol_version = @server_info["protocolVersion"]
|
||||||
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
||||||
# Per spec, if the client does not support the server's returned 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
|
# the client SHOULD disconnect. A modern version is rejected along with unknown ones:
|
||||||
|
# the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse
|
||||||
|
# a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising
|
||||||
# so a retry starts without a stale `server_info`.
|
# so a retry starts without a stale `server_info`.
|
||||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
||||||
@server_info = nil
|
@server_info = nil
|
||||||
|
|
@ -270,8 +276,6 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
notification = {
|
notification = {
|
||||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||||
|
|
@ -317,6 +321,10 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
|
||||||
|
# so the warning lives here now that the handshake cannot land on one.
|
||||||
|
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
|
||||||
|
|
||||||
@server_info = result
|
@server_info = result
|
||||||
@server_info
|
@server_info
|
||||||
end
|
end
|
||||||
|
|
@ -327,8 +335,13 @@ module MCP
|
||||||
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
||||||
# only serving legacy versions.
|
# only serving legacy versions.
|
||||||
def connect_auto(client_info:, protocol_version:, capabilities:)
|
def connect_auto(client_info:, protocol_version:, capabilities:)
|
||||||
connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities)
|
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
|
||||||
|
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
|
||||||
rescue RequestHandlerError
|
rescue RequestHandlerError
|
||||||
|
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
|
||||||
|
# so the probe's failure is the real answer and propagates.
|
||||||
|
raise if modern_pin
|
||||||
|
|
||||||
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,40 @@ module MCP
|
||||||
].freeze
|
].freeze
|
||||||
DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
|
DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
|
||||||
|
|
||||||
# Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575).
|
# Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575),
|
||||||
# 2026-07-28 serves both lifecycles of the dual-era model: it is negotiable through the legacy `initialize`
|
# where each request carries its own version in `_meta` and is validated against this list independently,
|
||||||
# handshake (so it also appears in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`), and it is the version of the modern
|
# with no handshake. These are reachable only through `server/discover` and the per-request envelope.
|
||||||
# lifecycle, where each request carries its own version in `_meta` and is validated against this list
|
|
||||||
# independently, with no handshake.
|
|
||||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
||||||
LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28"
|
LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28"
|
||||||
SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze
|
SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze
|
||||||
|
|
||||||
|
# Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition
|
||||||
|
# (handshake = stable minus modern) cannot drift when a new revision lands.
|
||||||
|
# Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish
|
||||||
|
# a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request
|
||||||
|
# in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version:
|
||||||
|
# a client asking `initialize` for one is counter-offered
|
||||||
|
# `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs.
|
||||||
|
SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze
|
||||||
|
LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def modern_protocol_version?(version)
|
def modern_protocol_version?(version)
|
||||||
SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version)
|
SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handshake_protocol_version?(version)
|
||||||
|
SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The one statement of the client-side handshake contract, shared by every transport:
|
||||||
|
# a modern version cannot ride the legacy `initialize` handshake.
|
||||||
|
def reject_modern_handshake_version!(version)
|
||||||
|
return unless version && modern_protocol_version?(version)
|
||||||
|
|
||||||
|
raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \
|
||||||
|
"`initialize` handshake; use `mode: :modern` (or `:auto`) instead"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_writer :exception_reporter, :around_request
|
attr_writer :exception_reporter, :around_request
|
||||||
|
|
@ -78,8 +99,12 @@ module MCP
|
||||||
@validate_tool_call_results = validate_tool_call_results
|
@validate_tool_call_results = validate_tool_call_results
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake
|
||||||
|
# settles on by default. Reading the newest version of any era here would hand back a value
|
||||||
|
# the writer rejects, and no caller wants the modern revision: a modern connection carries
|
||||||
|
# its version on every request instead of consulting configuration.
|
||||||
def protocol_version
|
def protocol_version
|
||||||
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION
|
@protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION
|
||||||
end
|
end
|
||||||
|
|
||||||
def protocol_version?
|
def protocol_version?
|
||||||
|
|
@ -174,11 +199,24 @@ module MCP
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# A pin scopes the `initialize` handshake, so only handshake versions are accepted:
|
||||||
|
# a modern version has no handshake to pin (its version rides every request in `_meta`),
|
||||||
|
# and accepting one here would configure nothing. Failing at construction beats
|
||||||
|
# a setting that silently does not apply.
|
||||||
def validate_protocol_version!(protocol_version)
|
def validate_protocol_version!(protocol_version)
|
||||||
unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version)
|
||||||
message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}"
|
|
||||||
raise ArgumentError, message
|
# A version this SDK serves, rejected only for where it was set, deserves the reason and
|
||||||
|
# the alternative rather than a list it is missing from: this is the error an upgrade from
|
||||||
|
# a release that accepted the value lands on, so it doubles as the migration note.
|
||||||
|
if self.class.modern_protocol_version?(protocol_version)
|
||||||
|
raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \
|
||||||
|
"the pin scopes the `initialize` handshake, which never negotiates a modern version. " \
|
||||||
|
"Modern clients carry their version on every request and need no pin; remove the setting."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \
|
||||||
|
"or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
|
def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@
|
||||||
require_relative "configuration"
|
require_relative "configuration"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
|
# Warning texts for the features SEP-2577 deprecates at 2026-07-28.
|
||||||
|
#
|
||||||
|
# Only the client emits these, from the modern connect, where the capabilities it declares are
|
||||||
|
# the ones being deprecated. No server-side trigger remains: the `initialize` handshake never lands on
|
||||||
|
# a deprecating revision and `Configuration` rejects a modern pin, so nothing on that side can reach
|
||||||
|
# a version these apply to. `LOGGING_MESSAGE` in particular has no caller left
|
||||||
|
# (a modern client declares no logging capability, and `notify_log_message` on that wire is
|
||||||
|
# the SEP-2575 sanctioned delivery path rather than a deprecated call). It stays public and
|
||||||
|
# callable for embedders, and because deleting it would be a breaking change for no gain.
|
||||||
module ProtocolDeprecations
|
module ProtocolDeprecations
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -403,8 +403,6 @@ module MCP
|
||||||
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
||||||
# Use stderr or OpenTelemetry instead.
|
# Use stderr or OpenTelemetry instead.
|
||||||
def notify_log_message(data:, level:, logger: nil)
|
def notify_log_message(data:, level:, logger: nil)
|
||||||
warn_if_deprecated_protocol_feature(:logging, uplevel: 1)
|
|
||||||
|
|
||||||
return unless @transport
|
return unless @transport
|
||||||
return unless logging_message_notification&.should_notify?(level)
|
return unless logging_message_notification&.should_notify?(level)
|
||||||
|
|
||||||
|
|
@ -425,8 +423,6 @@ module MCP
|
||||||
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
||||||
# server configuration, or environment variables instead.
|
# server configuration, or environment variables instead.
|
||||||
def roots_list_changed_handler(&block)
|
def roots_list_changed_handler(&block)
|
||||||
warn_if_deprecated_protocol_feature(:roots, uplevel: 1)
|
|
||||||
|
|
||||||
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -591,13 +587,6 @@ module MCP
|
||||||
return ->(params) { handle_cancelled_notification(params, session: session) }
|
return ->(params) { handle_cancelled_notification(params, session: session) }
|
||||||
end
|
end
|
||||||
|
|
||||||
case method
|
|
||||||
when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED
|
|
||||||
warn_if_deprecated_protocol_feature(:roots, session: session, uplevel: 2)
|
|
||||||
when Methods::LOGGING_SET_LEVEL
|
|
||||||
warn_if_deprecated_protocol_feature(:logging, session: session, uplevel: 2)
|
|
||||||
end
|
|
||||||
|
|
||||||
handler = @handlers[method]
|
handler = @handlers[method]
|
||||||
unless handler
|
unless handler
|
||||||
instrument_call("unsupported_method", server_context: { request: request }) do
|
instrument_call("unsupported_method", server_context: { request: request }) do
|
||||||
|
|
@ -1045,10 +1034,19 @@ module MCP
|
||||||
end
|
end
|
||||||
protocol_version = params[:protocolVersion]
|
protocol_version = params[:protocolVersion]
|
||||||
|
|
||||||
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
# Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is
|
||||||
|
# defined by carrying its own version on every request in `_meta`, with no handshake,
|
||||||
|
# so asking `initialize` for one (or for a version this server does not know) is answered with
|
||||||
|
# a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions
|
||||||
|
# stay reachable through `server/discover` and the per-request envelope. The counter-offer is
|
||||||
|
# a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there),
|
||||||
|
# and the latest handshake version otherwise.
|
||||||
|
negotiated_version = if Configuration.handshake_protocol_version?(protocol_version)
|
||||||
protocol_version
|
protocol_version
|
||||||
else
|
elsif configuration.protocol_version?
|
||||||
configuration.protocol_version
|
configuration.protocol_version
|
||||||
|
else
|
||||||
|
Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
||||||
end
|
end
|
||||||
|
|
||||||
info = server_info.reject do |property|
|
info = server_info.reject do |property|
|
||||||
|
|
@ -1160,19 +1158,6 @@ module MCP
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
def warn_if_deprecated_protocol_feature(feature, session: nil, uplevel: 1)
|
|
||||||
protocol_version = effective_deprecation_protocol_version(session)
|
|
||||||
MCP::ProtocolDeprecations.warn_for(feature, protocol_version: protocol_version, uplevel: uplevel)
|
|
||||||
end
|
|
||||||
|
|
||||||
def effective_deprecation_protocol_version(session)
|
|
||||||
session&.protocol_version || @client_protocol_version || explicit_protocol_version
|
|
||||||
end
|
|
||||||
|
|
||||||
def explicit_protocol_version
|
|
||||||
configuration.protocol_version if configuration.protocol_version?
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_tools(request)
|
def list_tools(request)
|
||||||
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,12 +277,12 @@ module MCP
|
||||||
# An empty header value is malformed rather than a version claim, so it stays on the legacy path
|
# An empty header value is malformed rather than a version claim, so it stays on the legacy path
|
||||||
# and fails legacy header validation as before.
|
# and fails legacy header validation as before.
|
||||||
#
|
#
|
||||||
# 2026-07-28 serves both lifecycles of the dual-era model, so for that header value the version
|
# A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it,
|
||||||
# alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session
|
# and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it):
|
||||||
# (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose
|
# an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream,
|
||||||
# body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is
|
# and DELETE termination keep working), and a session-less POST whose body is `initialize` is
|
||||||
# sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests
|
# the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic
|
||||||
# that get the modern path's error shape).
|
# (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
|
||||||
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
||||||
if header_version && !header_version.empty? && !stable_only_version?(header_version)
|
if header_version && !header_version.empty? && !stable_only_version?(header_version)
|
||||||
unless MCP::Configuration.modern_protocol_version?(header_version)
|
unless MCP::Configuration.modern_protocol_version?(header_version)
|
||||||
|
|
@ -1414,8 +1414,9 @@ module MCP
|
||||||
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
||||||
end
|
end
|
||||||
|
|
||||||
# A version negotiable only through the legacy handshake, with no modern meaning.
|
# A version with no modern meaning, whose header can only accompany legacy traffic.
|
||||||
# Dual-era versions (2026-07-28) appear in both lists and need further disambiguation.
|
# A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it,
|
||||||
|
# but requests of an established legacy session may stamp it - so that value needs further disambiguation.
|
||||||
def stable_only_version?(version)
|
def stable_only_version?(version)
|
||||||
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
|
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
|
||||||
!MCP::Configuration.modern_protocol_version?(version)
|
!MCP::Configuration.modern_protocol_version?(version)
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,6 @@ module MCP
|
||||||
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
||||||
# server configuration, or environment variables instead.
|
# server configuration, or environment variables instead.
|
||||||
def list_roots(related_request_id: nil, timeout: nil)
|
def list_roots(related_request_id: nil, timeout: nil)
|
||||||
@server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2)
|
|
||||||
warn_unassociated_request(__method__, related_request_id)
|
warn_unassociated_request(__method__, related_request_id)
|
||||||
|
|
||||||
unless client_capabilities&.dig(:roots)
|
unless client_capabilities&.dig(:roots)
|
||||||
|
|
@ -159,7 +158,6 @@ module MCP
|
||||||
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
|
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
|
||||||
# APIs instead.
|
# APIs instead.
|
||||||
def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
|
def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
|
||||||
@server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2)
|
|
||||||
warn_unassociated_request(__method__, related_request_id)
|
warn_unassociated_request(__method__, related_request_id)
|
||||||
|
|
||||||
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
||||||
|
|
@ -274,8 +272,6 @@ module MCP
|
||||||
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
||||||
# Use stderr or OpenTelemetry instead.
|
# Use stderr or OpenTelemetry instead.
|
||||||
def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
|
def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
|
||||||
@server.send(:warn_if_deprecated_protocol_feature, :logging, session: self, uplevel: 2)
|
|
||||||
|
|
||||||
# In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member
|
# In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member
|
||||||
# (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level
|
# (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level
|
||||||
# does not apply (SEP-2575).
|
# does not apply (SEP-2575).
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
module InitializeParamsTestHelper
|
module InitializeParamsTestHelper
|
||||||
# `initialize` params satisfying the fields required by the MCP schema
|
# `initialize` params satisfying the fields required by the MCP schema
|
||||||
# (protocolVersion, capabilities, and clientInfo).
|
# (protocolVersion, capabilities, and clientInfo). Offers the latest handshake version
|
||||||
|
# so helper-built sessions run the version they request; the counter-offer for
|
||||||
|
# modern versions has its own dedicated tests.
|
||||||
def initialize_params(**overrides)
|
def initialize_params(**overrides)
|
||||||
{
|
{
|
||||||
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: { name: "test-client", version: "1.0.0" },
|
clientInfo: { name: "test-client", version: "1.0.0" },
|
||||||
}.merge(overrides)
|
}.merge(overrides)
|
||||||
|
|
|
||||||
|
|
@ -1630,6 +1630,52 @@ module MCP
|
||||||
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_adopts_a_counter_offered_protocol_version_from_the_handshake
|
||||||
|
# A server may answer `initialize` with a version other than the one offered, and everything
|
||||||
|
# after the handshake speaks the answer rather than the offer. This server counter-offers
|
||||||
|
# a handshake version to clients asking `initialize` for a modern one, which only resolves
|
||||||
|
# anything because clients behave this way.
|
||||||
|
counter_offered = "2025-06-18"
|
||||||
|
refute_equal(
|
||||||
|
counter_offered,
|
||||||
|
MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||||
|
"the counter-offer has to differ from the offer for this to test anything",
|
||||||
|
)
|
||||||
|
|
||||||
|
offered = nil
|
||||||
|
stub_request(:post, url).with do |req|
|
||||||
|
body = JSON.parse(req.body)
|
||||||
|
offered = body.dig("params", "protocolVersion") if body["method"] == "initialize"
|
||||||
|
body["method"] == "initialize"
|
||||||
|
end.to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
|
||||||
|
body: { result: { protocolVersion: counter_offered } }.to_json,
|
||||||
|
)
|
||||||
|
stub_notification
|
||||||
|
|
||||||
|
client.connect
|
||||||
|
|
||||||
|
assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered)
|
||||||
|
assert_equal(counter_offered, client.protocol_version)
|
||||||
|
|
||||||
|
# Scoped to the body as well: `notifications/initialized` already went out under
|
||||||
|
# the adopted version, and a header-only stub would count that too.
|
||||||
|
header_stub = stub_request(:post, url).with(
|
||||||
|
headers: { "MCP-Protocol-Version" => counter_offered },
|
||||||
|
) do |req|
|
||||||
|
JSON.parse(req.body)["method"] == "tools/list"
|
||||||
|
end.to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: { result: { tools: [] } }.to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
||||||
|
|
||||||
|
assert_requested(header_stub)
|
||||||
|
end
|
||||||
|
|
||||||
def test_does_not_send_protocol_version_header_before_initialize
|
def test_does_not_send_protocol_version_header_before_initialize
|
||||||
stub_request(:post, url)
|
stub_request(:post, url)
|
||||||
.with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") }
|
.with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") }
|
||||||
|
|
@ -1884,14 +1930,14 @@ module MCP
|
||||||
.with do |req|
|
.with do |req|
|
||||||
body = JSON.parse(req.body)
|
body = JSON.parse(req.body)
|
||||||
body["method"] == "initialize" &&
|
body["method"] == "initialize" &&
|
||||||
body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION &&
|
body["params"]["protocolVersion"] == MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION &&
|
||||||
body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } &&
|
body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } &&
|
||||||
body["params"]["capabilities"] == {}
|
body["params"]["capabilities"] == {}
|
||||||
end
|
end
|
||||||
.to_return(
|
.to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type" => "application/json" },
|
headers: { "Content-Type" => "application/json" },
|
||||||
body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json,
|
body: { result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
client.connect
|
client.connect
|
||||||
|
|
@ -1927,9 +1973,31 @@ module MCP
|
||||||
assert_requested(notification_stub)
|
assert_requested(notification_stub)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28
|
def test_connect_offers_the_latest_handshake_version_by_default
|
||||||
notification_stub = stub_notification
|
# The handshake negotiates legacy versions only (SEP-2575 era model); modern versions are
|
||||||
|
# selected via `mode: :modern`/`:auto`, so the default offer is the latest handshake version.
|
||||||
|
offered = nil
|
||||||
|
init_stub = stub_request(:post, url).with do |req|
|
||||||
|
body = JSON.parse(req.body)
|
||||||
|
offered = body.dig("params", "protocolVersion") if body["method"] == "initialize"
|
||||||
|
body["method"] == "initialize"
|
||||||
|
end.to_return(
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type" => "application/json" },
|
||||||
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
||||||
|
)
|
||||||
|
stub_notification
|
||||||
|
|
||||||
|
client.connect
|
||||||
|
|
||||||
|
assert_requested(init_stub)
|
||||||
|
assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result
|
||||||
|
# A server answering `initialize` with a modern version is refused like an unknown one:
|
||||||
|
# the handshake settles on a legacy version by definition, and the TypeScript and Python clients
|
||||||
|
# refuse a modern counter-offer the same way.
|
||||||
init_stub = stub_request(:post, url)
|
init_stub = stub_request(:post, url)
|
||||||
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
||||||
.to_return(
|
.to_return(
|
||||||
|
|
@ -1938,12 +2006,20 @@ module MCP
|
||||||
body: { result: { protocolVersion: "2026-07-28" } }.to_json,
|
body: { result: { protocolVersion: "2026-07-28" } }.to_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
error = assert_raises(RequestHandlerError) do
|
||||||
client.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
client.connect
|
||||||
end
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "2026-07-28")
|
||||||
assert_requested(init_stub)
|
assert_requested(init_stub)
|
||||||
assert_requested(notification_stub)
|
end
|
||||||
|
|
||||||
|
def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
client.connect(protocol_version: "2026-07-28", mode: :legacy)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older
|
def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older
|
||||||
|
|
@ -2167,6 +2243,37 @@ module MCP
|
||||||
assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") }
|
assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_connect_auto_propagates_the_discovery_failure_for_an_explicitly_modern_version
|
||||||
|
# An explicitly requested modern version is never downgraded by the fallback:
|
||||||
|
# the legacy handshake cannot negotiate it, so the probe's failure is the real answer.
|
||||||
|
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) do
|
||||||
|
client.connect(mode: :auto, protocol_version: "2026-07-28")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "no mutually supported modern protocol version")
|
||||||
|
refute_predicate(client, :connected?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_connect_modern_warns_for_deprecated_capabilities
|
||||||
|
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every
|
||||||
|
# modern connection speaks.
|
||||||
|
stub_discover
|
||||||
|
|
||||||
|
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
||||||
|
client.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} })
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_predicate(client, :modern?)
|
||||||
|
end
|
||||||
|
|
||||||
def test_reconnect_after_close
|
def test_reconnect_after_close
|
||||||
stub_initialize
|
stub_initialize
|
||||||
stub_notification
|
stub_notification
|
||||||
|
|
|
||||||
|
|
@ -818,7 +818,56 @@ module MCP
|
||||||
stdout_write.close
|
stdout_write.close
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28
|
def test_connect_adopts_a_counter_offered_protocol_version
|
||||||
|
# A server may answer `initialize` with a version other than the one offered, and the connection
|
||||||
|
# then speaks the answer rather than the offer. This server counter-offers a handshake version
|
||||||
|
# to clients asking `initialize` for a modern one, which only resolves anything because
|
||||||
|
# clients behave this way.
|
||||||
|
counter_offered = "2025-06-18"
|
||||||
|
refute_equal(
|
||||||
|
counter_offered,
|
||||||
|
MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||||
|
"the counter-offer has to differ from the offer for this to test anything",
|
||||||
|
)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
offered = nil
|
||||||
|
|
||||||
|
server_thread = Thread.new do
|
||||||
|
init_request = JSON.parse(stdin_read.gets)
|
||||||
|
offered = init_request.dig("params", "protocolVersion")
|
||||||
|
stdout_write.puts(JSON.generate(
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: init_request["id"],
|
||||||
|
result: { protocolVersion: counter_offered },
|
||||||
|
))
|
||||||
|
stdout_write.flush
|
||||||
|
stdin_read.gets
|
||||||
|
end
|
||||||
|
|
||||||
|
transport.connect
|
||||||
|
|
||||||
|
assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered)
|
||||||
|
assert_equal(counter_offered, transport.protocol_version)
|
||||||
|
ensure
|
||||||
|
server_thread.join
|
||||||
|
stdin_read.close
|
||||||
|
stdin_write.close
|
||||||
|
stdout_read.close
|
||||||
|
stdout_write.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result
|
||||||
|
# A server answering `initialize` with a modern version is refused like an unknown one:
|
||||||
|
# the handshake settles on a legacy version by definition (SEP-2575 era model),
|
||||||
|
# and the TypeScript and Python clients refuse a modern counter-offer the same way.
|
||||||
stdin_read, stdin_write = IO.pipe
|
stdin_read, stdin_write = IO.pipe
|
||||||
stdout_read, stdout_write = IO.pipe
|
stdout_read, stdout_write = IO.pipe
|
||||||
stderr_read, _ = IO.pipe
|
stderr_read, _ = IO.pipe
|
||||||
|
|
@ -836,12 +885,14 @@ module MCP
|
||||||
result: { protocolVersion: "2026-07-28" },
|
result: { protocolVersion: "2026-07-28" },
|
||||||
))
|
))
|
||||||
stdout_write.flush
|
stdout_write.flush
|
||||||
stdin_read.gets
|
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
error = assert_raises(RequestHandlerError) do
|
||||||
transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "2026-07-28")
|
||||||
|
refute_predicate(transport, :connected?)
|
||||||
ensure
|
ensure
|
||||||
server_thread.join
|
server_thread.join
|
||||||
stdin_read.close
|
stdin_read.close
|
||||||
|
|
@ -850,6 +901,19 @@ module MCP
|
||||||
stdout_write.close
|
stdout_write.close
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake
|
||||||
|
# Validated before the child process is spawned: a pure argument error must not leave
|
||||||
|
# an orphaned server process behind.
|
||||||
|
Open3.expects(:popen3).never
|
||||||
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||||
|
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
transport.connect(protocol_version: "2026-07-28", mode: :legacy)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake")
|
||||||
|
end
|
||||||
|
|
||||||
def test_connect_raises_on_jsonrpc_error_response
|
def test_connect_raises_on_jsonrpc_error_response
|
||||||
stdin_read, stdin_write = IO.pipe
|
stdin_read, stdin_write = IO.pipe
|
||||||
stdout_read, stdout_write = IO.pipe
|
stdout_read, stdout_write = IO.pipe
|
||||||
|
|
@ -1498,6 +1562,43 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_connect_modern_warns_for_deprecated_capabilities
|
||||||
|
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks.
|
||||||
|
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"],
|
||||||
|
result: {
|
||||||
|
supportedVersions: ["2026-07-28"],
|
||||||
|
capabilities: {},
|
||||||
|
serverInfo: { name: "test-server", version: "1.0" },
|
||||||
|
ttlMs: 0,
|
||||||
|
cacheScope: "private",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
stdout_write.flush
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
||||||
|
transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} })
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_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 test_connect_auto_falls_back_to_the_legacy_handshake
|
def test_connect_auto_falls_back_to_the_legacy_handshake
|
||||||
stdin_read, stdin_write = IO.pipe
|
stdin_read, stdin_write = IO.pipe
|
||||||
stdout_read, stdout_write = IO.pipe
|
stdout_read, stdout_write = IO.pipe
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,21 @@ module MCP
|
||||||
assert_equal test_context, reported_context
|
assert_equal test_context, reported_context
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15
|
|
||||||
test "initializes with default protocol version" do
|
test "initializes with default protocol version" do
|
||||||
|
# The unset pin reads as the version the handshake settles on, which is also the only kind of
|
||||||
|
# value the writer accepts: reading back a version that cannot be set again would break code
|
||||||
|
# that round-trips the setting.
|
||||||
config = Configuration.new
|
config = Configuration.new
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version
|
|
||||||
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version
|
||||||
|
refute_predicate config, :protocol_version?
|
||||||
|
assert_nothing_raised { Configuration.new(protocol_version: config.protocol_version) }
|
||||||
end
|
end
|
||||||
|
|
||||||
test "uses the draft protocol version when protocol_version is set to nil" do
|
test "uses the default protocol version when protocol_version is set to nil" do
|
||||||
config = Configuration.new(protocol_version: nil)
|
config = Configuration.new(protocol_version: nil)
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version
|
|
||||||
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises ArgumentError when setting the draft protocol version" do
|
test "raises ArgumentError when setting the draft protocol version" do
|
||||||
|
|
@ -54,7 +60,7 @@ module MCP
|
||||||
Configuration.new(protocol_version: "DRAFT-2025-v3")
|
Configuration.new(protocol_version: "DRAFT-2025-v3")
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
|
assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises ArgumentError when protocol_version is not a supported protocol version" do
|
test "raises ArgumentError when protocol_version is not a supported protocol version" do
|
||||||
|
|
@ -63,7 +69,7 @@ module MCP
|
||||||
custom_version = "2025-03-27"
|
custom_version = "2025-03-27"
|
||||||
config.protocol_version = custom_version
|
config.protocol_version = custom_version
|
||||||
end
|
end
|
||||||
assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
|
assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "exposes the SEP-2575 modern protocol versions" do
|
test "exposes the SEP-2575 modern protocol versions" do
|
||||||
|
|
@ -73,11 +79,20 @@ module MCP
|
||||||
refute Configuration.modern_protocol_version?("2025-11-25")
|
refute Configuration.modern_protocol_version?("2025-11-25")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts 2026-07-28 as the protocol version" do
|
test "rejects a modern version as the protocol version pin" do
|
||||||
# 2026-07-28 serves both lifecycles of the dual-era model (SEP-2575), so it is negotiable through
|
# The pin scopes the `initialize` handshake; a modern version has no handshake to pin
|
||||||
# the legacy `initialize` handshake and settable as the fallback version.
|
# (its version rides every request in `_meta`), so accepting it would configure nothing.
|
||||||
config = Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION)
|
# The message names that reason instead of listing the accepted values, because this is
|
||||||
assert_equal "2026-07-28", config.protocol_version
|
# the error an upgrade from a release that accepted the value lands on.
|
||||||
|
exception = assert_raises(ArgumentError) do
|
||||||
|
Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_includes(exception.message, "is a modern protocol version and cannot be pinned here")
|
||||||
|
assert_includes(exception.message, "remove the setting")
|
||||||
|
|
||||||
|
config = Configuration.new
|
||||||
|
assert_raises(ArgumentError) { config.protocol_version = "2026-07-28" }
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises ArgumentError when protocol_version is not a boolean value" do
|
test "raises ArgumentError when protocol_version is not a boolean value" do
|
||||||
|
|
|
||||||
|
|
@ -563,15 +563,15 @@ module MCP
|
||||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "initialize negotiating 2026-07-28 still locks the legacy era" do
|
test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do
|
||||||
# 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through
|
# Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on
|
||||||
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
|
# the legacy lifecycle it selected, so a later modern envelope is still rejected.
|
||||||
responses = run_transport_session([
|
responses = run_transport_session([
|
||||||
initialize_request(id: 1, protocol_version: "2026-07-28"),
|
initialize_request(id: 1, protocol_version: "2026-07-28"),
|
||||||
modern_tools_list_request(id: 2),
|
modern_tools_list_request(id: 2),
|
||||||
])
|
])
|
||||||
|
|
||||||
assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion)
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, responses[0].dig(:result, :protocolVersion)
|
||||||
assert_equal :legacy, session_era
|
assert_equal :legacy, session_era
|
||||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ module MCP
|
||||||
body = JSON.parse(response[2][0])
|
body = JSON.parse(response[2][0])
|
||||||
assert_equal "2.0", body["jsonrpc"]
|
assert_equal "2.0", body["jsonrpc"]
|
||||||
assert_equal "123", body["id"]
|
assert_equal "123", body["id"]
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do
|
test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do
|
||||||
|
|
@ -1902,7 +1902,9 @@ module MCP
|
||||||
response = @transport.handle_request(request)
|
response = @transport.handle_request(request)
|
||||||
assert_equal 200, response[0]
|
assert_equal 200, response[0]
|
||||||
body = JSON.parse(response[2][0])
|
body = JSON.parse(response[2][0])
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
|
# The body's requested version drives negotiation (not the older header);
|
||||||
|
# the default modern request is counter-offered the latest handshake version.
|
||||||
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do
|
test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do
|
||||||
|
|
@ -2027,7 +2029,9 @@ module MCP
|
||||||
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json,
|
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json,
|
||||||
)
|
)
|
||||||
init_response = @transport.handle_request(init_request)
|
init_response = @transport.handle_request(init_request)
|
||||||
assert_equal "2026-07-28", JSON.parse(init_response[2][0])["result"]["protocolVersion"]
|
# The handshake never negotiates a modern version (SEP-2575 era model);
|
||||||
|
# the session proceeds on the counter-offered 2025-11-25.
|
||||||
|
assert_equal "2025-11-25", JSON.parse(init_response[2][0])["result"]["protocolVersion"]
|
||||||
session_id = init_response[1]["mcp-session-id"]
|
session_id = init_response[1]["mcp-session-id"]
|
||||||
|
|
||||||
request = create_rack_request(
|
request = create_rack_request(
|
||||||
|
|
@ -5784,9 +5788,10 @@ module MCP
|
||||||
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
|
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sessionless POST initialize with the dual-era header stays legacy and negotiates 2026-07-28" do
|
test "sessionless POST initialize with the dual-era header stays legacy and is counter-offered" do
|
||||||
# The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is
|
# The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is
|
||||||
# the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle.
|
# the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle
|
||||||
|
# and is counter-offered the latest handshake version (SEP-2575 era model).
|
||||||
request = create_rack_request(
|
request = create_rack_request(
|
||||||
"POST",
|
"POST",
|
||||||
"/",
|
"/",
|
||||||
|
|
@ -5797,10 +5802,16 @@ module MCP
|
||||||
response = @transport.handle_request(request)
|
response = @transport.handle_request(request)
|
||||||
assert_equal 200, response[0]
|
assert_equal 200, response[0]
|
||||||
refute_nil response[1]["mcp-session-id"]
|
refute_nil response[1]["mcp-session-id"]
|
||||||
assert_equal "2026-07-28", JSON.parse(response[2][0]).dig("result", "protocolVersion")
|
assert_equal "2025-11-25", JSON.parse(response[2][0]).dig("result", "protocolVersion")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "session-bound POST with the dual-era header stays on the legacy path" do
|
test "session-bound POST with the dual-era header stays on the legacy path" do
|
||||||
|
# A deliberately lenient reading. The header names a version the handshake refused to
|
||||||
|
# negotiate, so it disagrees with the session; the session wins and the request is served
|
||||||
|
# at the negotiated revision, unstamped. Rejecting instead would read the spec's
|
||||||
|
# "invalid or unsupported MCP-Protocol-Version MUST be 400" as covering a version this
|
||||||
|
# server does in fact support (through the modern lifecycle), and would break a client
|
||||||
|
# over what is a SHOULD on its side: sending the negotiated version rather than its own.
|
||||||
session_id = initialize_test_session
|
session_id = initialize_test_session
|
||||||
|
|
||||||
response = @transport.handle_request(create_rack_request(
|
response = @transport.handle_request(create_rack_request(
|
||||||
|
|
@ -5815,6 +5826,13 @@ module MCP
|
||||||
))
|
))
|
||||||
|
|
||||||
assert_equal 200, response[0]
|
assert_equal 200, response[0]
|
||||||
|
|
||||||
|
io = StringIO.new
|
||||||
|
response[2].call(io)
|
||||||
|
body = sse_events(io).first
|
||||||
|
|
||||||
|
# No `resultType`: the header claim does not move the session onto the modern wire.
|
||||||
|
refute(body["result"].key?("resultType"))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "session-bound DELETE with the dual-era header terminates the legacy session" do
|
test "session-bound DELETE with the dual-era header terminates the legacy session" do
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,7 @@ module MCP
|
||||||
|
|
||||||
test "initialize request cannot be cancelled" do
|
test "initialize request cannot be cancelled" do
|
||||||
init_params = {
|
init_params = {
|
||||||
protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||||
clientInfo: { name: "test", version: "1.0" },
|
clientInfo: { name: "test", version: "1.0" },
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,49 +85,25 @@ module MCP
|
||||||
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method]
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#notify_log_message warns when configured protocol version is 2026-07-28" do
|
test "#notify_log_message does not warn on any server configuration" do
|
||||||
server = Server.new(
|
# A modern pin is rejected at configuration time and the handshake never lands on
|
||||||
name: "test_server",
|
# a deprecating revision, so no server-side path can reach the SEP-2577 warnings;
|
||||||
version: "1.0.0",
|
# the notification itself still goes out (or is suppressed below the configured level).
|
||||||
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
server = Server.new(name: "test_server", version: "1.0.0")
|
||||||
)
|
|
||||||
mock_transport = MockTransport.new(server)
|
mock_transport = MockTransport.new(server)
|
||||||
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method]
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method]
|
||||||
end
|
|
||||||
|
|
||||||
test "#notify_log_message warns when configured protocol version is 2026-07-28 without transport" do
|
assert_no_deprecation_warning do
|
||||||
server = Server.new(
|
|
||||||
name: "test_server",
|
|
||||||
version: "1.0.0",
|
|
||||||
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
|
||||||
)
|
|
||||||
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
||||||
server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "#notify_log_message warns when configured protocol version is 2026-07-28 below configured level" do
|
|
||||||
server = Server.new(
|
|
||||||
name: "test_server",
|
|
||||||
version: "1.0.0",
|
|
||||||
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
|
||||||
)
|
|
||||||
mock_transport = MockTransport.new(server)
|
|
||||||
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
||||||
server.notify_log_message(data: { message: "test" }, level: "info")
|
server.notify_log_message(data: { message: "test" }, level: "info")
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_empty mock_transport.notifications
|
assert_equal 1, mock_transport.notifications.size
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#notify_log_message does not warn when configured protocol version is older" do
|
test "#notify_log_message does not warn when configured protocol version is older" do
|
||||||
|
|
@ -144,7 +120,9 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ServerSession#notify_log_message warns when negotiated protocol version is 2026-07-28 below configured level" do
|
test "ServerSession#notify_log_message does not warn after a modern initialize request is counter-offered" do
|
||||||
|
# `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), where logging
|
||||||
|
# is not deprecated; the below-configured-level suppression still applies.
|
||||||
server = Server.new(name: "test_server", version: "1.0.0")
|
server = Server.new(name: "test_server", version: "1.0.0")
|
||||||
mock_transport = MockTransport.new(server)
|
mock_transport = MockTransport.new(server)
|
||||||
session = ServerSession.new(server: server, transport: mock_transport)
|
session = ServerSession.new(server: server, transport: mock_transport)
|
||||||
|
|
@ -163,7 +141,7 @@ module MCP
|
||||||
)
|
)
|
||||||
session.configure_logging(MCP::LoggingMessageNotification.new(level: "error"))
|
session.configure_logging(MCP::LoggingMessageNotification.new(level: "error"))
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
session.notify_log_message(data: { message: "test" }, level: "info")
|
session.notify_log_message(data: { message: "test" }, level: "info")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ module MCP
|
||||||
assert callback_called
|
assert callback_called
|
||||||
end
|
end
|
||||||
|
|
||||||
test "notifications/roots/list_changed warns when negotiated protocol version is 2026-07-28" do
|
test "notifications/roots/list_changed does not warn after a modern initialize request is counter-offered" do
|
||||||
|
# `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model),
|
||||||
|
# where roots is not deprecated.
|
||||||
server = Server.new(name: "test", version: "1.0")
|
server = Server.new(name: "test", version: "1.0")
|
||||||
server.handle({
|
server.handle({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -77,7 +79,7 @@ module MCP
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Roots .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
server.handle({
|
server.handle({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "notifications/roots/list_changed",
|
method: "notifications/roots/list_changed",
|
||||||
|
|
@ -176,13 +178,14 @@ module MCP
|
||||||
assert_silent { session.list_roots(related_request_id: "req-1") }
|
assert_silent { session.list_roots(related_request_id: "req-1") }
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ServerSession#list_roots warns when negotiated protocol version is 2026-07-28 and client lacks roots" do
|
test "ServerSession#list_roots raises without a warning when the client lacks roots" do
|
||||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
# A modern pin is rejected at configuration time and the handshake never lands on
|
||||||
|
# a deprecating revision, so no server-side path can reach the SEP-2577 warnings.
|
||||||
|
session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern)
|
||||||
session.store_client_info(client: { name: "test-client" }, capabilities: {})
|
session.store_client_info(client: { name: "test-client" }, capabilities: {})
|
||||||
session.mark_initialized!(protocol_version: "2026-07-28")
|
|
||||||
|
|
||||||
error = nil
|
error = nil
|
||||||
assert_deprecation_warning(/MCP Roots .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
error = assert_raises(RuntimeError) do
|
error = assert_raises(RuntimeError) do
|
||||||
session.list_roots(related_request_id: "req-1")
|
session.list_roots(related_request_id: "req-1")
|
||||||
end
|
end
|
||||||
|
|
@ -267,7 +270,9 @@ module MCP
|
||||||
assert_equal("No active stream for roots/list request.", error.message)
|
assert_equal("No active stream for roots/list request.", error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ServerSession#list_roots warns when session negotiated protocol version is 2026-07-28" do
|
test "ServerSession#list_roots does not warn after a modern initialize request is counter-offered" do
|
||||||
|
# `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model),
|
||||||
|
# where roots is not deprecated; the request itself still goes out.
|
||||||
server = Server.new(name: "test", version: "1.0")
|
server = Server.new(name: "test", version: "1.0")
|
||||||
transport = MockTransport.new(server)
|
transport = MockTransport.new(server)
|
||||||
|
|
||||||
|
|
@ -286,7 +291,7 @@ module MCP
|
||||||
session: session,
|
session: session,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Roots .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
session.list_roots(related_request_id: "req-1")
|
session.list_roots(related_request_id: "req-1")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,14 @@ module MCP
|
||||||
assert_equal "Response from LLM", result[:content][:text]
|
assert_equal "Response from LLM", result[:content][:text]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create_sampling_message warns when session negotiated protocol version is 2026-07-28" do
|
test "create_sampling_message does not warn on a modern-era session" do
|
||||||
@session.mark_initialized!(protocol_version: "2026-07-28")
|
# A modern pin is rejected at configuration time and the handshake never lands on
|
||||||
|
# a deprecating revision, so no server-side path can reach the SEP-2577 warnings.
|
||||||
|
session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern)
|
||||||
|
session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} })
|
||||||
|
|
||||||
assert_deprecation_warning(/MCP Sampling .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
@session.create_sampling_message(
|
session.create_sampling_message(
|
||||||
related_request_id: "req-1",
|
related_request_id: "req-1",
|
||||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||||
max_tokens: 100,
|
max_tokens: 100,
|
||||||
|
|
@ -126,24 +129,6 @@ module MCP
|
||||||
assert_equal("Client does not support sampling.", error.message)
|
assert_equal("Client does not support sampling.", error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create_sampling_message warns when negotiated protocol version is 2026-07-28 and client lacks sampling" do
|
|
||||||
@session.store_client_info(client: { name: "test-client" }, capabilities: {})
|
|
||||||
@session.mark_initialized!(protocol_version: "2026-07-28")
|
|
||||||
|
|
||||||
error = nil
|
|
||||||
assert_deprecation_warning(/MCP Sampling .*2026-07-28/) do
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@session.create_sampling_message(
|
|
||||||
related_request_id: "req-1",
|
|
||||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
|
||||||
max_tokens: 100,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal("Client does not support sampling.", error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "create_sampling_message raises error when tools used but client lacks sampling.tools" do
|
test "create_sampling_message raises error when tools used but client lacks sampling.tools" do
|
||||||
error = assert_raises(RuntimeError) do
|
error = assert_raises(RuntimeError) do
|
||||||
@session.create_sampling_message(
|
@session.create_sampling_message(
|
||||||
|
|
|
||||||
|
|
@ -430,9 +430,9 @@ module MCP
|
||||||
refute_nil response[:result]
|
refute_nil response[:result]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do
|
test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do
|
||||||
# Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope:
|
# Per the SEP-2575 era model, the handshake never lands on a modern version: the client is
|
||||||
# the handshake still belongs to the legacy lifecycle and locks that era.
|
# counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected.
|
||||||
session = ServerSession.new(server: @server, transport: mock)
|
session = ServerSession.new(server: @server, transport: mock)
|
||||||
|
|
||||||
response = @server.handle(
|
response = @server.handle(
|
||||||
|
|
@ -445,7 +445,7 @@ module MCP
|
||||||
session: session,
|
session: session,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_equal "2026-07-28", response.dig(:result, :protocolVersion)
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response.dig(:result, :protocolVersion)
|
||||||
assert_equal :legacy, session.era
|
assert_equal :legacy, session.era
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -724,7 +724,7 @@ module MCP
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id: 1,
|
id: 1,
|
||||||
result: {
|
result: {
|
||||||
protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
prompts: { listChanged: true },
|
prompts: { listChanged: true },
|
||||||
resources: { listChanged: true },
|
resources: { listChanged: true },
|
||||||
|
|
@ -916,7 +916,7 @@ module MCP
|
||||||
params: initialize_params(protocolVersion: "1999-01-01"),
|
params: initialize_params(protocolVersion: "1999-01-01"),
|
||||||
})
|
})
|
||||||
|
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion]
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "instrumentation data does not include client key when no clientInfo provided" do
|
test "instrumentation data does not include client key when no clientInfo provided" do
|
||||||
|
|
@ -1721,7 +1721,10 @@ module MCP
|
||||||
refute response.key?(:error)
|
refute response.key?(:error)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#configure_logging_level warns when negotiated protocol version is 2026-07-28" do
|
test "#configure_logging_level does not warn after a modern initialize request is counter-offered" do
|
||||||
|
# `initialize` asking for 2026-07-28 lands on 2025-11-25, where logging is not deprecated,
|
||||||
|
# so no warning fires. On the modern lifecycle itself `logging/setLevel` is a removed method
|
||||||
|
# and never reaches this handler.
|
||||||
server = Server.new(tools: [TestTool])
|
server = Server.new(tools: [TestTool])
|
||||||
server.handle(
|
server.handle(
|
||||||
{
|
{
|
||||||
|
|
@ -1737,7 +1740,7 @@ module MCP
|
||||||
)
|
)
|
||||||
|
|
||||||
response = nil
|
response = nil
|
||||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
assert_no_deprecation_warning do
|
||||||
response = server.handle(
|
response = server.handle(
|
||||||
{
|
{
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -1833,35 +1836,6 @@ module MCP
|
||||||
assert_includes response[:error][:data], "Server does not support logging"
|
assert_includes response[:error][:data], "Server does not support logging"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#configure_logging_level warns when configured protocol version is 2026-07-28 and server lacks logging capability" do
|
|
||||||
server = Server.new(
|
|
||||||
tools: [TestTool],
|
|
||||||
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
|
||||||
capabilities: {
|
|
||||||
tools: { listChanged: true },
|
|
||||||
prompts: { listChanged: true },
|
|
||||||
resources: { listChanged: true },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = nil
|
|
||||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
||||||
response = server.handle(
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "logging/setLevel",
|
|
||||||
params: {
|
|
||||||
level: "debug",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal(-32603, response[:error][:code])
|
|
||||||
assert_includes response[:error][:data], "Server does not support logging"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "#handle method with missing required top-level capability returns an error" do
|
test "#handle method with missing required top-level capability returns an error" do
|
||||||
@server.capabilities = {}
|
@server.capabilities = {}
|
||||||
|
|
||||||
|
|
@ -2213,7 +2187,7 @@ module MCP
|
||||||
assert_equal local_exception_reporter, server.configuration.exception_reporter
|
assert_equal local_exception_reporter, server.configuration.exception_reporter
|
||||||
end
|
end
|
||||||
|
|
||||||
test "server uses default protocol version when not configured" do
|
test "server uses the latest handshake version when not configured" do
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "initialize",
|
method: "initialize",
|
||||||
|
|
@ -2222,7 +2196,7 @@ module MCP
|
||||||
}
|
}
|
||||||
|
|
||||||
response = @server.handle(request)
|
response = @server.handle(request)
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion]
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "server response does not include optional parameters when configured" do
|
test "server response does not include optional parameters when configured" do
|
||||||
|
|
@ -2612,7 +2586,7 @@ module MCP
|
||||||
assert_equal "2025-06-18", response[:result][:protocolVersion]
|
assert_equal "2025-06-18", response[:result][:protocolVersion]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "server falls back to default version when client requests unsupported version" do
|
test "server falls back to the latest handshake version when client requests unsupported version" do
|
||||||
server = Server.new(name: "test_server")
|
server = Server.new(name: "test_server")
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
|
|
@ -2623,10 +2597,13 @@ module MCP
|
||||||
}
|
}
|
||||||
|
|
||||||
response = server.handle(request)
|
response = server.handle(request)
|
||||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion]
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "server negotiates 2026-07-28 when the client requests it via initialize" do
|
test "server counter-offers 2025-11-25 when the client requests 2026-07-28 via initialize" do
|
||||||
|
# Per the SEP-2575 era model, `initialize` negotiates legacy versions only: a modern version carries
|
||||||
|
# its own version on every request and has no handshake at all. The TypeScript and Python servers
|
||||||
|
# answer the same way.
|
||||||
server = Server.new(name: "test_server")
|
server = Server.new(name: "test_server")
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
|
|
@ -2637,7 +2614,7 @@ module MCP
|
||||||
}
|
}
|
||||||
|
|
||||||
response = server.handle(request)
|
response = server.handle(request)
|
||||||
assert_equal "2026-07-28", response[:result][:protocolVersion]
|
assert_equal "2025-11-25", response[:result][:protocolVersion]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "server removes description and icons from server_info when negotiating to 2025-06-18" do
|
test "server removes description and icons from server_info when negotiating to 2025-06-18" do
|
||||||
|
|
@ -4362,6 +4339,23 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "results after a counter-offered modern initialize carry no resultType" do
|
||||||
|
# The scenario from issue #512: a client asking `initialize` for 2026-07-28 lands on
|
||||||
|
# 2025-11-25, where `resultType` does not exist, so its absence is the correct shape
|
||||||
|
# (clients MUST read an absent `resultType` as "complete" on that revision).
|
||||||
|
server = Server.new(name: "result_type_test", tools: [result_type_tool])
|
||||||
|
session = ServerSession.new(server: server, transport: mock)
|
||||||
|
|
||||||
|
init_response = server.handle(
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28") },
|
||||||
|
session: session,
|
||||||
|
)
|
||||||
|
response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 2 }, session: session)
|
||||||
|
|
||||||
|
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, init_response.dig(:result, :protocolVersion)
|
||||||
|
refute response[:result].key?(:resultType)
|
||||||
|
end
|
||||||
|
|
||||||
test "server/discover carries resultType complete" do
|
test "server/discover carries resultType complete" do
|
||||||
server = Server.new(name: "result_type_test", tools: [result_type_tool])
|
server = Server.new(name: "result_type_test", tools: [result_type_tool])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue