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)
|
||||
```
|
||||
|
||||
If no protocol version is specified, the latest stable version will be applied by default.
|
||||
The latest stable version includes new features from the [draft version](https://modelcontextprotocol.io/specification/draft).
|
||||
If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Exception Reporting
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Call `MCP::Client#connect` to perform the MCP [initialization handshake](https:/
|
|||
|
||||
```ruby
|
||||
client.connect
|
||||
# => { "protocolVersion" => "2026-07-28", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
|
||||
client.connected? # => true
|
||||
client.server_info # => cached InitializeResult
|
||||
|
|
@ -99,7 +99,7 @@ After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` heade
|
|||
|
||||
```ruby
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -327,8 +327,9 @@ module MCP
|
|||
#
|
||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
||||
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
||||
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
||||
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
||||
# 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 `{}`.
|
||||
# @return [Hash] The server's `InitializeResult`.
|
||||
# @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)
|
||||
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 }
|
||||
|
||||
case mode
|
||||
|
|
@ -559,7 +563,7 @@ module MCP
|
|||
attr_reader :headers
|
||||
|
||||
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: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
|
|
@ -593,7 +597,10 @@ module MCP
|
|||
|
||||
@server_info = response["result"]
|
||||
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
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
||||
|
|
@ -602,8 +609,6 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
|
||||
|
||||
begin
|
||||
send_request(request: {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
|
|
@ -647,6 +652,10 @@ module MCP
|
|||
)
|
||||
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
|
||||
@connected = true
|
||||
@server_info
|
||||
|
|
@ -658,8 +667,13 @@ module MCP
|
|||
# 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)
|
||||
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
|
||||
# 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)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -77,8 +77,9 @@ module MCP
|
|||
#
|
||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
||||
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
||||
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
||||
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
||||
# 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 `{}`.
|
||||
# @return [Hash] The server's `InitializeResult`.
|
||||
# @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)
|
||||
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
|
||||
|
||||
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
||||
|
|
@ -221,7 +225,7 @@ module MCP
|
|||
private
|
||||
|
||||
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 = {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
|
|
@ -257,9 +261,11 @@ module MCP
|
|||
@server_info = response["result"]
|
||||
|
||||
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,
|
||||
# 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`.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
||||
@server_info = nil
|
||||
|
|
@ -270,8 +276,6 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
|
||||
|
||||
begin
|
||||
notification = {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
|
|
@ -317,6 +321,10 @@ module MCP
|
|||
)
|
||||
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
|
||||
end
|
||||
|
|
@ -327,8 +335,13 @@ module MCP
|
|||
# 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)
|
||||
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
|
||||
# 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)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -9,19 +9,40 @@ module MCP
|
|||
].freeze
|
||||
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).
|
||||
# 2026-07-28 serves both lifecycles of the dual-era model: it is negotiable through the legacy `initialize`
|
||||
# handshake (so it also appears in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`), and it is the version of the modern
|
||||
# lifecycle, where each request carries its own version in `_meta` and is validated against this list
|
||||
# independently, with no handshake.
|
||||
# Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575),
|
||||
# where each request carries its own version in `_meta` and is validated against this list independently,
|
||||
# with no handshake. These are reachable only through `server/discover` and the per-request envelope.
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
||||
LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28"
|
||||
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
|
||||
def modern_protocol_version?(version)
|
||||
SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version)
|
||||
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
|
||||
|
||||
attr_writer :exception_reporter, :around_request
|
||||
|
|
@ -78,8 +99,12 @@ module MCP
|
|||
@validate_tool_call_results = validate_tool_call_results
|
||||
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
|
||||
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION
|
||||
@protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION
|
||||
end
|
||||
|
||||
def protocol_version?
|
||||
|
|
@ -174,11 +199,24 @@ module MCP
|
|||
|
||||
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)
|
||||
unless SUPPORTED_STABLE_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
|
||||
return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version)
|
||||
|
||||
# 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
|
||||
|
||||
raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \
|
||||
"or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}"
|
||||
end
|
||||
|
||||
def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@
|
|||
require_relative "configuration"
|
||||
|
||||
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
|
||||
extend self
|
||||
|
||||
|
|
|
|||
|
|
@ -403,8 +403,6 @@ module MCP
|
|||
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
||||
# Use stderr or OpenTelemetry instead.
|
||||
def notify_log_message(data:, level:, logger: nil)
|
||||
warn_if_deprecated_protocol_feature(:logging, uplevel: 1)
|
||||
|
||||
return unless @transport
|
||||
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,
|
||||
# server configuration, or environment variables instead.
|
||||
def roots_list_changed_handler(&block)
|
||||
warn_if_deprecated_protocol_feature(:roots, uplevel: 1)
|
||||
|
||||
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
||||
end
|
||||
|
||||
|
|
@ -591,13 +587,6 @@ module MCP
|
|||
return ->(params) { handle_cancelled_notification(params, session: session) }
|
||||
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]
|
||||
unless handler
|
||||
instrument_call("unsupported_method", server_context: { request: request }) do
|
||||
|
|
@ -1045,10 +1034,19 @@ module MCP
|
|||
end
|
||||
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
|
||||
else
|
||||
elsif configuration.protocol_version?
|
||||
configuration.protocol_version
|
||||
else
|
||||
Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
||||
end
|
||||
|
||||
info = server_info.reject do |property|
|
||||
|
|
@ -1160,19 +1158,6 @@ module MCP
|
|||
{}
|
||||
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)
|
||||
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
|
||||
# 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
|
||||
# alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session
|
||||
# (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose
|
||||
# body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is
|
||||
# sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests
|
||||
# that get the modern path's error shape).
|
||||
# A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it,
|
||||
# and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it):
|
||||
# an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream,
|
||||
# and DELETE termination keep working), and a session-less POST whose body is `initialize` is
|
||||
# the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic
|
||||
# (`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"]
|
||||
if header_version && !header_version.empty? && !stable_only_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
|
||||
end
|
||||
|
||||
# A version negotiable only through the legacy handshake, with no modern meaning.
|
||||
# Dual-era versions (2026-07-28) appear in both lists and need further disambiguation.
|
||||
# A version with no modern meaning, whose header can only accompany legacy traffic.
|
||||
# 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)
|
||||
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
|
||||
!MCP::Configuration.modern_protocol_version?(version)
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ module MCP
|
|||
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
||||
# server configuration, or environment variables instead.
|
||||
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)
|
||||
|
||||
unless client_capabilities&.dig(:roots)
|
||||
|
|
@ -159,7 +158,6 @@ module MCP
|
|||
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
|
||||
# APIs instead.
|
||||
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)
|
||||
|
||||
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).
|
||||
# Use stderr or OpenTelemetry instead.
|
||||
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
|
||||
# (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level
|
||||
# does not apply (SEP-2575).
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
module InitializeParamsTestHelper
|
||||
# `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)
|
||||
{
|
||||
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
||||
protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test-client", version: "1.0.0" },
|
||||
}.merge(overrides)
|
||||
|
|
|
|||
|
|
@ -1630,6 +1630,52 @@ module MCP
|
|||
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
||||
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
|
||||
stub_request(:post, url)
|
||||
.with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") }
|
||||
|
|
@ -1884,14 +1930,14 @@ module MCP
|
|||
.with do |req|
|
||||
body = JSON.parse(req.body)
|
||||
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"]["capabilities"] == {}
|
||||
end
|
||||
.to_return(
|
||||
status: 200,
|
||||
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
|
||||
|
|
@ -1927,9 +1973,31 @@ module MCP
|
|||
assert_requested(notification_stub)
|
||||
end
|
||||
|
||||
def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28
|
||||
notification_stub = stub_notification
|
||||
def test_connect_offers_the_latest_handshake_version_by_default
|
||||
# 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)
|
||||
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
||||
.to_return(
|
||||
|
|
@ -1938,12 +2006,20 @@ module MCP
|
|||
body: { result: { protocolVersion: "2026-07-28" } }.to_json,
|
||||
)
|
||||
|
||||
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
||||
client.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.connect
|
||||
end
|
||||
|
||||
assert_includes(error.message, "2026-07-28")
|
||||
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
|
||||
|
||||
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") }
|
||||
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
|
||||
stub_initialize
|
||||
stub_notification
|
||||
|
|
|
|||
|
|
@ -818,7 +818,56 @@ module MCP
|
|||
stdout_write.close
|
||||
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
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
|
@ -836,12 +885,14 @@ module MCP
|
|||
result: { protocolVersion: "2026-07-28" },
|
||||
))
|
||||
stdout_write.flush
|
||||
stdin_read.gets
|
||||
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: {} })
|
||||
end
|
||||
|
||||
assert_includes(error.message, "2026-07-28")
|
||||
refute_predicate(transport, :connected?)
|
||||
ensure
|
||||
server_thread.join
|
||||
stdin_read.close
|
||||
|
|
@ -850,6 +901,19 @@ module MCP
|
|||
stdout_write.close
|
||||
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
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
|
|
@ -1498,6 +1562,43 @@ module MCP
|
|||
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
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
|
|
|
|||
|
|
@ -36,15 +36,21 @@ module MCP
|
|||
assert_equal test_context, reported_context
|
||||
end
|
||||
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version
|
||||
|
||||
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version
|
||||
end
|
||||
|
||||
test "raises ArgumentError when setting the draft protocol version" do
|
||||
|
|
@ -54,7 +60,7 @@ module MCP
|
|||
Configuration.new(protocol_version: "DRAFT-2025-v3")
|
||||
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
|
||||
|
||||
test "raises ArgumentError when protocol_version is not a supported protocol version" do
|
||||
|
|
@ -63,7 +69,7 @@ module MCP
|
|||
custom_version = "2025-03-27"
|
||||
config.protocol_version = custom_version
|
||||
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
|
||||
|
||||
test "exposes the SEP-2575 modern protocol versions" do
|
||||
|
|
@ -73,11 +79,20 @@ module MCP
|
|||
refute Configuration.modern_protocol_version?("2025-11-25")
|
||||
end
|
||||
|
||||
test "accepts 2026-07-28 as the protocol version" do
|
||||
# 2026-07-28 serves both lifecycles of the dual-era model (SEP-2575), so it is negotiable through
|
||||
# the legacy `initialize` handshake and settable as the fallback version.
|
||||
config = Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION)
|
||||
assert_equal "2026-07-28", config.protocol_version
|
||||
test "rejects a modern version as the protocol version pin" do
|
||||
# The pin scopes the `initialize` handshake; a modern version has no handshake to pin
|
||||
# (its version rides every request in `_meta`), so accepting it would configure nothing.
|
||||
# The message names that reason instead of listing the accepted values, because this is
|
||||
# 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
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
test "initialize negotiating 2026-07-28 still locks the legacy era" do
|
||||
# 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through
|
||||
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
|
||||
test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do
|
||||
# Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on
|
||||
# the legacy lifecycle it selected, so a later modern envelope is still rejected.
|
||||
responses = run_transport_session([
|
||||
initialize_request(id: 1, protocol_version: "2026-07-28"),
|
||||
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 JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ module MCP
|
|||
body = JSON.parse(response[2][0])
|
||||
assert_equal "2.0", body["jsonrpc"]
|
||||
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
|
||||
|
||||
test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do
|
||||
|
|
@ -1902,7 +1902,9 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 200, response[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
|
||||
|
||||
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,
|
||||
)
|
||||
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"]
|
||||
|
||||
request = create_rack_request(
|
||||
|
|
@ -5784,9 +5788,10 @@ module MCP
|
|||
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
|
||||
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 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(
|
||||
"POST",
|
||||
"/",
|
||||
|
|
@ -5797,10 +5802,16 @@ module MCP
|
|||
response = @transport.handle_request(request)
|
||||
assert_equal 200, response[0]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
response = @transport.handle_request(create_rack_request(
|
||||
|
|
@ -5815,6 +5826,13 @@ module MCP
|
|||
))
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
init_params = {
|
||||
protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
||||
protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||
clientInfo: { name: "test", version: "1.0" },
|
||||
capabilities: {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,49 +85,25 @@ module MCP
|
|||
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method]
|
||||
end
|
||||
|
||||
test "#notify_log_message warns when configured protocol version is 2026-07-28" do
|
||||
server = Server.new(
|
||||
name: "test_server",
|
||||
version: "1.0.0",
|
||||
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
||||
)
|
||||
test "#notify_log_message does not warn on any server configuration" do
|
||||
# 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;
|
||||
# the notification itself still goes out (or is suppressed below the configured level).
|
||||
server = Server.new(name: "test_server", version: "1.0.0")
|
||||
mock_transport = MockTransport.new(server)
|
||||
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")
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
assert_no_deprecation_warning do
|
||||
server.notify_log_message(data: { message: "test" }, level: "info")
|
||||
end
|
||||
|
||||
assert_empty mock_transport.notifications
|
||||
assert_equal 1, mock_transport.notifications.size
|
||||
end
|
||||
|
||||
test "#notify_log_message does not warn when configured protocol version is older" do
|
||||
|
|
@ -144,7 +120,9 @@ module MCP
|
|||
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")
|
||||
mock_transport = MockTransport.new(server)
|
||||
session = ServerSession.new(server: server, transport: mock_transport)
|
||||
|
|
@ -163,7 +141,7 @@ module MCP
|
|||
)
|
||||
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")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ module MCP
|
|||
assert callback_called
|
||||
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.handle({
|
||||
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({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/roots/list_changed",
|
||||
|
|
@ -176,13 +178,14 @@ module MCP
|
|||
assert_silent { session.list_roots(related_request_id: "req-1") }
|
||||
end
|
||||
|
||||
test "ServerSession#list_roots warns when negotiated protocol version is 2026-07-28 and client lacks roots" do
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
test "ServerSession#list_roots raises without a warning when the client lacks roots" do
|
||||
# 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.mark_initialized!(protocol_version: "2026-07-28")
|
||||
|
||||
error = nil
|
||||
assert_deprecation_warning(/MCP Roots .*2026-07-28/) do
|
||||
assert_no_deprecation_warning do
|
||||
error = assert_raises(RuntimeError) do
|
||||
session.list_roots(related_request_id: "req-1")
|
||||
end
|
||||
|
|
@ -267,7 +270,9 @@ module MCP
|
|||
assert_equal("No active stream for roots/list request.", error.message)
|
||||
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")
|
||||
transport = MockTransport.new(server)
|
||||
|
||||
|
|
@ -286,7 +291,7 @@ module MCP
|
|||
session: session,
|
||||
)
|
||||
|
||||
assert_deprecation_warning(/MCP Roots .*2026-07-28/) do
|
||||
assert_no_deprecation_warning do
|
||||
session.list_roots(related_request_id: "req-1")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -64,11 +64,14 @@ module MCP
|
|||
assert_equal "Response from LLM", result[:content][:text]
|
||||
end
|
||||
|
||||
test "create_sampling_message warns when session negotiated protocol version is 2026-07-28" do
|
||||
@session.mark_initialized!(protocol_version: "2026-07-28")
|
||||
test "create_sampling_message does not warn on a modern-era session" do
|
||||
# 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
|
||||
@session.create_sampling_message(
|
||||
assert_no_deprecation_warning do
|
||||
session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
|
|
@ -126,24 +129,6 @@ module MCP
|
|||
assert_equal("Client does not support sampling.", error.message)
|
||||
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
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_sampling_message(
|
||||
|
|
|
|||
|
|
@ -430,9 +430,9 @@ module MCP
|
|||
refute_nil response[:result]
|
||||
end
|
||||
|
||||
test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do
|
||||
# Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope:
|
||||
# the handshake still belongs to the legacy lifecycle and locks that era.
|
||||
test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do
|
||||
# Per the SEP-2575 era model, the handshake never lands on a modern version: the client is
|
||||
# counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected.
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
response = @server.handle(
|
||||
|
|
@ -445,7 +445,7 @@ module MCP
|
|||
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
|
||||
end
|
||||
|
||||
|
|
@ -724,7 +724,7 @@ module MCP
|
|||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
||||
protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
prompts: { listChanged: true },
|
||||
resources: { listChanged: true },
|
||||
|
|
@ -916,7 +916,7 @@ module MCP
|
|||
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
|
||||
|
||||
test "instrumentation data does not include client key when no clientInfo provided" do
|
||||
|
|
@ -1721,7 +1721,10 @@ module MCP
|
|||
refute response.key?(:error)
|
||||
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.handle(
|
||||
{
|
||||
|
|
@ -1737,7 +1740,7 @@ module MCP
|
|||
)
|
||||
|
||||
response = nil
|
||||
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
||||
assert_no_deprecation_warning do
|
||||
response = server.handle(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
|
|
@ -1833,35 +1836,6 @@ module MCP
|
|||
assert_includes response[:error][:data], "Server does not support logging"
|
||||
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
|
||||
@server.capabilities = {}
|
||||
|
||||
|
|
@ -2213,7 +2187,7 @@ module MCP
|
|||
assert_equal local_exception_reporter, server.configuration.exception_reporter
|
||||
end
|
||||
|
||||
test "server uses default protocol version when not configured" do
|
||||
test "server uses the latest handshake version when not configured" do
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
|
|
@ -2222,7 +2196,7 @@ module MCP
|
|||
}
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
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")
|
||||
|
||||
request = {
|
||||
|
|
@ -2623,10 +2597,13 @@ module MCP
|
|||
}
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
request = {
|
||||
|
|
@ -2637,7 +2614,7 @@ module MCP
|
|||
}
|
||||
|
||||
response = server.handle(request)
|
||||
assert_equal "2026-07-28", response[:result][:protocolVersion]
|
||||
assert_equal "2025-11-25", response[:result][:protocolVersion]
|
||||
end
|
||||
|
||||
test "server removes description and icons from server_info when negotiating to 2025-06-18" do
|
||||
|
|
@ -4362,6 +4339,23 @@ module MCP
|
|||
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
|
||||
server = Server.new(name: "result_type_test", tools: [result_type_tool])
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue