Skip to content

Commit a62e56e

Browse files
authored
Merge pull request #516 from koic/counter_offer_legacy_version_on_initialize
Stop negotiating modern protocol versions through the `initialize` handshake
2 parents 84eac10 + 48b7aaa commit a62e56e

20 files changed

Lines changed: 472 additions & 207 deletions

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,8 +585,7 @@ configuration = MCP::Configuration.new(protocol_version: "2024-11-05")
585585
MCP::Server.new(name: "test_server", configuration: configuration)
586586
```
587587

588-
If no protocol version is specified, the latest stable version will be applied by default.
589-
The latest stable version includes new features from the [draft version](https://modelcontextprotocol.io/specification/draft).
588+
If no protocol version is specified, the latest handshake version (`2025-11-25`) is applied by default.
590589

591590
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`:
592591

@@ -596,6 +595,11 @@ MCP::Configuration.new(protocol_version: nil)
596595

597596
If an invalid `protocol_version` value is set, an `ArgumentError` is raised.
598597

598+
The pin scopes the `initialize` handshake, so it accepts handshake versions (`2025-11-25` and earlier) only. Per the SEP-2575 era model,
599+
`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`;
600+
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.
601+
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.
602+
599603
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.
600604

601605
### Exception Reporting

docs/building-clients.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Call `MCP::Client#connect` to perform the MCP [initialization handshake](https:/
2222

2323
```ruby
2424
client.connect
25-
# => { "protocolVersion" => "2026-07-28", "capabilities" => {...}, "serverInfo" => {...} }
25+
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
2626

2727
client.connected? # => true
2828
client.server_info # => cached InitializeResult
@@ -99,7 +99,7 @@ After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` heade
9999

100100
```ruby
101101
http_transport.session_id # => "abc123..."
102-
http_transport.protocol_version # => "2026-07-28"
102+
http_transport.protocol_version # => "2025-11-25"
103103
```
104104

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

lib/mcp/client/http.rb

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,9 @@ def on_server_request(method, &handler)
327327
#
328328
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
329329
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
330-
# @param protocol_version [String, nil] Protocol version to offer. Defaults
331-
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
330+
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
331+
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
332+
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
332333
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
333334
# @return [Hash] The server's `InitializeResult`.
334335
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
@@ -341,6 +342,9 @@ def on_server_request(method, &handler)
341342
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
342343
return @server_info if connected?
343344

345+
# Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake.
346+
MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
347+
344348
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
345349

346350
case mode
@@ -559,7 +563,7 @@ def close
559563
attr_reader :headers
560564

561565
def connect_legacy(client_info:, protocol_version:, capabilities:)
562-
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
566+
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
563567

564568
response = send_request(request: {
565569
jsonrpc: JsonRpcHandler::Version::V2_0,
@@ -593,7 +597,10 @@ def connect_legacy(client_info:, protocol_version:, capabilities:)
593597

594598
@server_info = response["result"]
595599
negotiated_protocol_version = @server_info["protocolVersion"]
596-
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
600+
# A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake
601+
# settles on a legacy version by definition, and the TypeScript and Python clients refuse
602+
# a modern counter-offer the same way.
603+
unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
597604
clear_session
598605
raise RequestHandlerError.new(
599606
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
@@ -602,8 +609,6 @@ def connect_legacy(client_info:, protocol_version:, capabilities:)
602609
)
603610
end
604611

605-
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
606-
607612
begin
608613
send_request(request: {
609614
jsonrpc: JsonRpcHandler::Version::V2_0,
@@ -647,6 +652,10 @@ def connect_modern(client_info:, protocol_version:, capabilities:)
647652
)
648653
end
649654

655+
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
656+
# so the warning lives here now that the handshake cannot land on one.
657+
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
658+
650659
@server_info = result
651660
@connected = true
652661
@server_info
@@ -658,8 +667,13 @@ def connect_modern(client_info:, protocol_version:, capabilities:)
658667
# version as well: during the 2026-07-28 rollout a server may answer discovery while
659668
# only serving legacy versions.
660669
def connect_auto(client_info:, protocol_version:, capabilities:)
661-
connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities)
670+
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
671+
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
662672
rescue RequestHandlerError
673+
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
674+
# so the probe's failure is the real answer and propagates.
675+
raise if modern_pin
676+
663677
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
664678
end
665679

lib/mcp/client/stdio.rb

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes:
7777
#
7878
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
7979
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
80-
# @param protocol_version [String, nil] Protocol version to offer. Defaults
81-
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
80+
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
81+
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
82+
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
8283
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
8384
# @return [Hash] The server's `InitializeResult`.
8485
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
@@ -91,6 +92,9 @@ def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes:
9192
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
9293
return @server_info if connected?
9394

95+
# Validated before `start` so a pure argument error never spawns the server process.
96+
MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
97+
9498
start unless @started
9599

96100
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
@@ -221,7 +225,7 @@ def close
221225
private
222226

223227
def connect_legacy(client_info:, protocol_version:, capabilities:)
224-
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
228+
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
225229

226230
init_request = {
227231
jsonrpc: JsonRpcHandler::Version::V2_0,
@@ -257,9 +261,11 @@ def connect_legacy(client_info:, protocol_version:, capabilities:)
257261
@server_info = response["result"]
258262

259263
negotiated_protocol_version = @server_info["protocolVersion"]
260-
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
264+
unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
261265
# Per spec, if the client does not support the server's returned protocol version,
262-
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before raising
266+
# the client SHOULD disconnect. A modern version is rejected along with unknown ones:
267+
# the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse
268+
# a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising
263269
# so a retry starts without a stale `server_info`.
264270
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
265271
@server_info = nil
@@ -270,8 +276,6 @@ def connect_legacy(client_info:, protocol_version:, capabilities:)
270276
)
271277
end
272278

273-
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
274-
275279
begin
276280
notification = {
277281
jsonrpc: JsonRpcHandler::Version::V2_0,
@@ -317,6 +321,10 @@ def connect_modern(client_info:, protocol_version:, capabilities:)
317321
)
318322
end
319323

324+
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
325+
# so the warning lives here now that the handshake cannot land on one.
326+
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
327+
320328
@server_info = result
321329
@server_info
322330
end
@@ -327,8 +335,13 @@ def connect_modern(client_info:, protocol_version:, capabilities:)
327335
# version as well: during the 2026-07-28 rollout a server may answer discovery while
328336
# only serving legacy versions.
329337
def connect_auto(client_info:, protocol_version:, capabilities:)
330-
connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities)
338+
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
339+
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
331340
rescue RequestHandlerError
341+
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
342+
# so the probe's failure is the real answer and propagates.
343+
raise if modern_pin
344+
332345
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
333346
end
334347

lib/mcp/configuration.rb

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,40 @@ class Configuration
99
].freeze
1010
DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
1111

12-
# Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575).
13-
# 2026-07-28 serves both lifecycles of the dual-era model: it is negotiable through the legacy `initialize`
14-
# handshake (so it also appears in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`), and it is the version of the modern
15-
# lifecycle, where each request carries its own version in `_meta` and is validated against this list
16-
# independently, with no handshake.
12+
# Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575),
13+
# where each request carries its own version in `_meta` and is validated against this list independently,
14+
# with no handshake. These are reachable only through `server/discover` and the per-request envelope.
1715
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
1816
LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28"
1917
SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze
2018

19+
# Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition
20+
# (handshake = stable minus modern) cannot drift when a new revision lands.
21+
# Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish
22+
# a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request
23+
# in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version:
24+
# a client asking `initialize` for one is counter-offered
25+
# `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs.
26+
SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze
27+
LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first
28+
2129
class << self
2230
def modern_protocol_version?(version)
2331
SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version)
2432
end
33+
34+
def handshake_protocol_version?(version)
35+
SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version)
36+
end
37+
38+
# The one statement of the client-side handshake contract, shared by every transport:
39+
# a modern version cannot ride the legacy `initialize` handshake.
40+
def reject_modern_handshake_version!(version)
41+
return unless version && modern_protocol_version?(version)
42+
43+
raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \
44+
"`initialize` handshake; use `mode: :modern` (or `:auto`) instead"
45+
end
2546
end
2647

2748
attr_writer :exception_reporter, :around_request
@@ -78,8 +99,12 @@ def validate_tool_call_results=(validate_tool_call_results)
7899
@validate_tool_call_results = validate_tool_call_results
79100
end
80101

102+
# The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake
103+
# settles on by default. Reading the newest version of any era here would hand back a value
104+
# the writer rejects, and no caller wants the modern revision: a modern connection carries
105+
# its version on every request instead of consulting configuration.
81106
def protocol_version
82-
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION
107+
@protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION
83108
end
84109

85110
def protocol_version?
@@ -174,11 +199,24 @@ def merge(other)
174199

175200
private
176201

202+
# A pin scopes the `initialize` handshake, so only handshake versions are accepted:
203+
# a modern version has no handshake to pin (its version rides every request in `_meta`),
204+
# and accepting one here would configure nothing. Failing at construction beats
205+
# a setting that silently does not apply.
177206
def validate_protocol_version!(protocol_version)
178-
unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
179-
message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}"
180-
raise ArgumentError, message
207+
return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version)
208+
209+
# A version this SDK serves, rejected only for where it was set, deserves the reason and
210+
# the alternative rather than a list it is missing from: this is the error an upgrade from
211+
# a release that accepted the value lands on, so it doubles as the migration note.
212+
if self.class.modern_protocol_version?(protocol_version)
213+
raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \
214+
"the pin scopes the `initialize` handshake, which never negotiates a modern version. " \
215+
"Modern clients carry their version on every request and need no pin; remove the setting."
181216
end
217+
218+
raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \
219+
"or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}"
182220
end
183221

184222
def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)

lib/mcp/protocol_deprecations.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
require_relative "configuration"
44

55
module MCP
6+
# Warning texts for the features SEP-2577 deprecates at 2026-07-28.
7+
#
8+
# Only the client emits these, from the modern connect, where the capabilities it declares are
9+
# the ones being deprecated. No server-side trigger remains: the `initialize` handshake never lands on
10+
# a deprecating revision and `Configuration` rejects a modern pin, so nothing on that side can reach
11+
# a version these apply to. `LOGGING_MESSAGE` in particular has no caller left
12+
# (a modern client declares no logging capability, and `notify_log_message` on that wire is
13+
# the SEP-2575 sanctioned delivery path rather than a deprecated call). It stays public and
14+
# callable for embedders, and because deleting it would be a breaking change for no gain.
615
module ProtocolDeprecations
716
extend self
817

0 commit comments

Comments
 (0)