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
7 changed files with 472 additions and 207 deletions

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