From 48b7aaacd9ba1fd366a3a0922051d395ce8d5d3a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 14 Aug 2026 05:34:33 +0900 Subject: [PATCH] Stop negotiating modern protocol versions through the `initialize` handshake ## Motivation and Context Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761 A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are both gated on the per-request `_meta` envelope, which such a connection never carries, so strictly validating clients reject every result as missing the REQUIRED `resultType`. The root cause sits upstream of the stamp. 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 their version on every request in `_meta` with no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry; the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too, and counter-offers `LATEST_HANDSHAKE_VERSION`. `initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list (`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know, is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on `2025-11-25`, where `resultType` does not exist and its absence is the correct shape, resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is no longer constructible, so the gates are correct by construction. The client side is symmetrized the same way, matching both reference SDKs: the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default, passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError` (validated before the stdio transport spawns its child process), and a server answering `initialize` with a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to the discovery probe and never downgrades it into the legacy fallback: the probe's failure is the real answer and propagates, instead of the fallback dying on the handshake guard. The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`), and the handshake list is derived from the stable and modern lists so the era partition cannot drift when the next revision lands. Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land on a deprecating version and the call site the warnings used to sit on had become unreachable. On the server the answer is removal: `Configuration#protocol_version` scopes the handshake, so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction. Accepting one would configure nothing (a modern version has no handshake to pin), and neither reference SDK carries a config value that is accepted but inert: Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions` is consumed by its own era's mechanism. With no path left onto a deprecating revision, the server-side deprecation warning plumbing became unreachable and is removed along with it. Keying those warnings on a modern-era session instead was tried and rejected, because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line would then carry a deprecation warning for doing exactly what the revision prescribes (a single test file produced over a thousand of them). A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered, and such a request continues to be served at the negotiated revision rather than rejected. Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`, through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with its consequence, which is that no `resultType` appears on those results. Two details follow from the pin being handshake-scoped. Reading it back when nothing is set now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era, so the getter no longer hands out a value its own writer rejects and round-tripping the setting keeps working; only `Server#validate!` reads that default, through three comparisons that answer the same for either value, so nothing else moves. And the rejection names its reason instead of listing the accepted versions, because this is the error an upgrade from a release that accepted the value lands on, which makes it the migration note a bare list would not be. `MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now, and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be a breaking change for no gain. The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper now use the handshake version as well, and the README section on `protocol_version` describes the pin as handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default, and `2026-07-28` reached through `server/discover` with no configuration needed. ## How Has This Been Tested? New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType` (the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer, the `ArgumentError` for an explicitly modern version on the legacy connect (asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult` on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities. Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version, the answer is an older one, and the connection speaks the answer afterwards (over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption from an echo, because every one of them had the server answer with the version the client had just offered. Both new tests were confirmed to fail when the client is made to report its own offer instead. Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked: the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer, and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires. The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version, or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths, since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests on the constructor and the setter, asserting the reason in the message rather than the list of accepted values. The default-pin test now also round-trips what it reads back into a fresh `Configuration`, which is what the old default could not do. The default move was checked for neutrality outside the suite as well, by building a server that uses every feature `Server#validate!` gates on that value (`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations) and confirming all of them are still accepted. ## Breaking Changes Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through the handshake are counter-offered `2025-11-25` and are served at that revision, matching the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now raises `ArgumentError` (the pin scopes the handshake, which cannot land there); the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway. Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`; nothing in this SDK behaves differently for either value, but code comparing that reader against a literal will see the change. The client no longer offers modern versions on the legacy handshake and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision; the client-side warning on a modern connect replaces them. --- README.md | 8 +- docs/building-clients.md | 4 +- lib/mcp/client/http.rb | 28 +++- lib/mcp/client/stdio.rb | 29 +++-- lib/mcp/configuration.rb | 56 ++++++-- lib/mcp/protocol_deprecations.rb | 9 ++ lib/mcp/server.rb | 37 ++---- .../transports/streamable_http_transport.rb | 17 +-- lib/mcp/server_session.rb | 4 - test/initialize_params_test_helper.rb | 6 +- test/mcp/client/http_test.rb | 121 +++++++++++++++++- test/mcp/client/stdio_test.rb | 107 +++++++++++++++- test/mcp/configuration_test.rb | 37 ++++-- .../server/transports/stdio_transport_test.rb | 8 +- .../streamable_http_transport_test.rb | 30 ++++- test/mcp/server_cancellation_test.rb | 2 +- test/mcp/server_notification_test.rb | 46 ++----- test/mcp/server_roots_test.rb | 21 +-- test/mcp/server_sampling_test.rb | 29 +---- test/mcp/server_test.rb | 80 ++++++------ 20 files changed, 472 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index bf21f707..f335bf94 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/building-clients.md b/docs/building-clients.md index 9dfaf0d8..6b4c7d01 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -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. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 1a7b4d4a..e189c74f 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -327,8 +327,9 @@ def on_server_request(method, &handler) # # @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 @@ def on_server_request(method, &handler) 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 @@ def close 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 @@ def connect_legacy(client_info:, protocol_version:, capabilities:) @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 @@ def connect_legacy(client_info:, protocol_version:, capabilities:) ) 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 @@ def connect_modern(client_info:, protocol_version:, capabilities:) ) 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 @@ def connect_modern(client_info:, protocol_version:, capabilities:) # 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 diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index d26528f5..fee1a3d9 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -77,8 +77,9 @@ def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: # # @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 @@ def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: 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 @@ def close 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 @@ def connect_legacy(client_info:, protocol_version:, capabilities:) @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 @@ def connect_legacy(client_info:, protocol_version:, capabilities:) ) 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 @@ def connect_modern(client_info:, protocol_version:, capabilities:) ) 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 @@ def connect_modern(client_info:, protocol_version:, capabilities:) # 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 diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 0dac2237..4d4b78e0 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -9,19 +9,40 @@ class Configuration ].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 @@ def validate_tool_call_results=(validate_tool_call_results) @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 @@ def merge(other) 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) diff --git a/lib/mcp/protocol_deprecations.rb b/lib/mcp/protocol_deprecations.rb index ee561618..f79e727d 100644 --- a/lib/mcp/protocol_deprecations.rb +++ b/lib/mcp/protocol_deprecations.rb @@ -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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 5fa024be..2eff700b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -403,8 +403,6 @@ def notify_resources_list_changed # 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 @@ def notify_log_message(data:, level:, logger: nil) # 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 @@ def handle_request(request, method, session: nil, related_request_id: nil) 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 @@ def init(params, session: nil) 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 @@ def configure_logging_level(request, session: nil) {} 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) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 48e8de2a..e420dc0d 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -277,12 +277,12 @@ def handle_request(request) # 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 @@ def discover_request?(body) 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) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 31497364..d12b18c2 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -132,7 +132,6 @@ def client_capabilities # 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 @@ def ping(related_request_id: nil, timeout: nil) # 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 @@ def notify_progress(progress_token:, progress:, total: nil, message: nil, relate # 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). diff --git a/test/initialize_params_test_helper.rb b/test/initialize_params_test_helper.rb index 6e6d6b23..ccee933c 100644 --- a/test/initialize_params_test_helper.rb +++ b/test/initialize_params_test_helper.rb @@ -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) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 7d0d8590..87cdb551 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1630,6 +1630,52 @@ def test_includes_session_and_protocol_version_headers_after_initialize 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 @@ def test_connect_uses_default_client_info_and_protocol_version .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 @@ def test_connect_accepts_custom_parameters 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 @@ def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_vers 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 @@ def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode 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 diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 4a04bfe6..3b160402 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -818,7 +818,56 @@ def test_connect_accepts_custom_parameters 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 @@ def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_vers 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 @@ def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_vers 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 @@ def test_connect_modern_probes_discover_and_stamps_requests 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 diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 6aa10cc8..058bc191 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -36,15 +36,21 @@ class ConfigurationTest < ActiveSupport::TestCase 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 @@ class ConfigurationTest < ActiveSupport::TestCase 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 @@ class ConfigurationTest < ActiveSupport::TestCase 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 @@ class ConfigurationTest < ActiveSupport::TestCase 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 diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index eef0a7c0..683e2ae4 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -563,15 +563,15 @@ class StdioTransportTest < ActiveSupport::TestCase 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 diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 30853563..26b89060 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -248,7 +248,7 @@ def string 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 @@ def string 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 @@ def string { 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 @@ def string 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 @@ def string 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 @@ def string )) 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 diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index 9b1908a2..a91bf1ae 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -304,7 +304,7 @@ def handle_request(request); end 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: {}, } diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 9e51a036..ffeb3a8e 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -85,49 +85,25 @@ def handle_request(request); 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" 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 @@ def handle_request(request); end end end - test "ServerSession#notify_log_message warns when negotiated protocol version is 2026-07-28 below configured level" do + test "ServerSession#notify_log_message does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), where logging + # is not deprecated; the below-configured-level suppression still applies. server = Server.new(name: "test_server", version: "1.0.0") mock_transport = MockTransport.new(server) session = ServerSession.new(server: server, transport: mock_transport) @@ -163,7 +141,7 @@ def handle_request(request); end ) 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 diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index 283fbdda..39c4e8ff 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -64,7 +64,9 @@ def close; end 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 @@ def close; end }, }) - 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 @@ def close; end 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 @@ def close; end 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 @@ def close; end 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 diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index b2c84f57..c5d0d591 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -64,11 +64,14 @@ def close; end 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 @@ def close; end 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( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 480827e9..64408154 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -430,9 +430,9 @@ class ServerTest < ActiveSupport::TestCase 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 @@ class ServerTest < ActiveSupport::TestCase 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 @@ class ServerTest < ActiveSupport::TestCase 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 @@ class ServerTest < ActiveSupport::TestCase 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 @@ -1715,7 +1715,10 @@ def read_resource_request(uri) 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( { @@ -1731,7 +1734,7 @@ def read_resource_request(uri) ) response = nil - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do response = server.handle( { jsonrpc: "2.0", @@ -1827,35 +1830,6 @@ def read_resource_request(uri) 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 = {} @@ -2207,7 +2181,7 @@ def read_resource_request(uri) 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", @@ -2216,7 +2190,7 @@ def read_resource_request(uri) } 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 @@ -2606,7 +2580,7 @@ def read_resource_request(uri) 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 = { @@ -2617,10 +2591,13 @@ def read_resource_request(uri) } 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 = { @@ -2631,7 +2608,7 @@ def read_resource_request(uri) } 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 @@ -4356,6 +4333,23 @@ def server_context 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])