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:
Koichi ITO 2026-08-15 11:32:15 +09:00 • committed by GitHub
commit a62e56ef48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 472 additions and 207 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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).

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: {},
}

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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])