diff --git a/README.md b/README.md index bf21f70..f335bf9 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 9dfaf0d..6b4c7d0 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 1a7b4d4..e189c74 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -327,8 +327,9 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. - # @param protocol_version [String, nil] Protocol version to offer. Defaults - # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. + # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. + # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version + # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error @@ -341,6 +342,9 @@ module MCP def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? + # Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake. + MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } case mode @@ -559,7 +563,7 @@ module MCP attr_reader :headers def connect_legacy(client_info:, protocol_version:, capabilities:) - protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION response = send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -593,7 +597,10 @@ module MCP @server_info = response["result"] negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + # A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake + # settles on a legacy version by definition, and the TypeScript and Python clients refuse + # a modern counter-offer the same way. + unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) clear_session raise RequestHandlerError.new( "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", @@ -602,8 +609,6 @@ module MCP ) end - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) - begin send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -647,6 +652,10 @@ module MCP ) end + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, + # so the warning lives here now that the handshake cannot land on one. + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) + @server_info = result @connected = true @server_info @@ -658,8 +667,13 @@ module MCP # version as well: during the 2026-07-28 rollout a server may answer discovery while # only serving legacy versions. def connect_auto(client_info:, protocol_version:, capabilities:) - connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities) + modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) + connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) rescue RequestHandlerError + # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, + # so the probe's failure is the real answer and propagates. + raise if modern_pin + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) end diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index d26528f..fee1a3d 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -77,8 +77,9 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. - # @param protocol_version [String, nil] Protocol version to offer. Defaults - # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. + # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. + # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version + # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error, @@ -91,6 +92,9 @@ module MCP def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? + # Validated before `start` so a pure argument error never spawns the server process. + MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + start unless @started client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } @@ -221,7 +225,7 @@ module MCP private def connect_legacy(client_info:, protocol_version:, capabilities:) - protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION + protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION init_request = { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -257,9 +261,11 @@ module MCP @server_info = response["result"] negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) # Per spec, if the client does not support the server's returned protocol version, - # the client SHOULD disconnect. Roll back the cached `InitializeResult` before raising + # the client SHOULD disconnect. A modern version is rejected along with unknown ones: + # the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse + # a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising # so a retry starts without a stale `server_info`. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation @server_info = nil @@ -270,8 +276,6 @@ module MCP ) end - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1) - begin notification = { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -317,6 +321,10 @@ module MCP ) end + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, + # so the warning lives here now that the handshake cannot land on one. + MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) + @server_info = result @server_info end @@ -327,8 +335,13 @@ module MCP # version as well: during the 2026-07-28 rollout a server may answer discovery while # only serving legacy versions. def connect_auto(client_info:, protocol_version:, capabilities:) - connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities) + modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) + connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) rescue RequestHandlerError + # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, + # so the probe's failure is the real answer and propagates. + raise if modern_pin + connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) end diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 0dac223..4d4b78e 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -9,19 +9,40 @@ module MCP ].freeze DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" - # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575). - # 2026-07-28 serves both lifecycles of the dual-era model: it is negotiable through the legacy `initialize` - # handshake (so it also appears in `SUPPORTED_STABLE_PROTOCOL_VERSIONS`), and it is the version of the modern - # lifecycle, where each request carries its own version in `_meta` and is validated against this list - # independently, with no handshake. + # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575), + # where each request carries its own version in `_meta` and is validated against this list independently, + # with no handshake. These are reachable only through `server/discover` and the per-request envelope. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28" SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze + # Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition + # (handshake = stable minus modern) cannot drift when a new revision lands. + # Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish + # a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request + # in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version: + # a client asking `initialize` for one is counter-offered + # `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs. + SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze + LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first + class << self def modern_protocol_version?(version) SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version) end + + def handshake_protocol_version?(version) + SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version) + end + + # The one statement of the client-side handshake contract, shared by every transport: + # a modern version cannot ride the legacy `initialize` handshake. + def reject_modern_handshake_version!(version) + return unless version && modern_protocol_version?(version) + + raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \ + "`initialize` handshake; use `mode: :modern` (or `:auto`) instead" + end end attr_writer :exception_reporter, :around_request @@ -78,8 +99,12 @@ module MCP @validate_tool_call_results = validate_tool_call_results end + # The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake + # settles on by default. Reading the newest version of any era here would hand back a value + # the writer rejects, and no caller wants the modern revision: a modern connection carries + # its version on every request instead of consulting configuration. def protocol_version - @protocol_version || LATEST_STABLE_PROTOCOL_VERSION + @protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION end def protocol_version? @@ -174,11 +199,24 @@ module MCP private + # A pin scopes the `initialize` handshake, so only handshake versions are accepted: + # a modern version has no handshake to pin (its version rides every request in `_meta`), + # and accepting one here would configure nothing. Failing at construction beats + # a setting that silently does not apply. def validate_protocol_version!(protocol_version) - unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) - message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}" - raise ArgumentError, message + return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version) + + # A version this SDK serves, rejected only for where it was set, deserves the reason and + # the alternative rather than a list it is missing from: this is the error an upgrade from + # a release that accepted the value lands on, so it doubles as the migration note. + if self.class.modern_protocol_version?(protocol_version) + raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \ + "the pin scopes the `initialize` handshake, which never negotiates a modern version. " \ + "Modern clients carry their version on every request and need no pin; remove the setting." end + + raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \ + "or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}" end def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) diff --git a/lib/mcp/protocol_deprecations.rb b/lib/mcp/protocol_deprecations.rb index ee56161..f79e727 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 5fa024b..2eff700 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -403,8 +403,6 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil) - warn_if_deprecated_protocol_feature(:logging, uplevel: 1) - return unless @transport return unless logging_message_notification&.should_notify?(level) @@ -425,8 +423,6 @@ module MCP # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. def roots_list_changed_handler(&block) - warn_if_deprecated_protocol_feature(:roots, uplevel: 1) - @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block end @@ -591,13 +587,6 @@ module MCP return ->(params) { handle_cancelled_notification(params, session: session) } end - case method - when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED - warn_if_deprecated_protocol_feature(:roots, session: session, uplevel: 2) - when Methods::LOGGING_SET_LEVEL - warn_if_deprecated_protocol_feature(:logging, session: session, uplevel: 2) - end - handler = @handlers[method] unless handler instrument_call("unsupported_method", server_context: { request: request }) do @@ -1045,10 +1034,19 @@ module MCP end protocol_version = params[:protocolVersion] - negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) + # Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is + # defined by carrying its own version on every request in `_meta`, with no handshake, + # so asking `initialize` for one (or for a version this server does not know) is answered with + # a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions + # stay reachable through `server/discover` and the per-request envelope. The counter-offer is + # a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there), + # and the latest handshake version otherwise. + negotiated_version = if Configuration.handshake_protocol_version?(protocol_version) protocol_version - else + elsif configuration.protocol_version? configuration.protocol_version + else + Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION end info = server_info.reject do |property| @@ -1160,19 +1158,6 @@ module MCP {} end - def warn_if_deprecated_protocol_feature(feature, session: nil, uplevel: 1) - protocol_version = effective_deprecation_protocol_version(session) - MCP::ProtocolDeprecations.warn_for(feature, protocol_version: protocol_version, uplevel: uplevel) - end - - def effective_deprecation_protocol_version(session) - session&.protocol_version || @client_protocol_version || explicit_protocol_version - end - - def explicit_protocol_version - configuration.protocol_version if configuration.protocol_version? - end - def list_tools(request) page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 48e8de2..e420dc0 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -277,12 +277,12 @@ module MCP # An empty header value is malformed rather than a version claim, so it stays on the legacy path # and fails legacy header validation as before. # - # 2026-07-28 serves both lifecycles of the dual-era model, so for that header value the version - # alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session - # (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose - # body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is - # sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests - # that get the modern path's error shape). + # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it, + # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it): + # an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream, + # and DELETE termination keep working), and a session-less POST whose body is `initialize` is + # the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic + # (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] if header_version && !header_version.empty? && !stable_only_version?(header_version) unless MCP::Configuration.modern_protocol_version?(header_version) @@ -1414,8 +1414,9 @@ module MCP body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER end - # A version negotiable only through the legacy handshake, with no modern meaning. - # Dual-era versions (2026-07-28) appear in both lists and need further disambiguation. + # A version with no modern meaning, whose header can only accompany legacy traffic. + # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it, + # but requests of an established legacy session may stamp it - so that value needs further disambiguation. def stable_only_version?(version) MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) && !MCP::Configuration.modern_protocol_version?(version) diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 3149736..d12b18c 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -132,7 +132,6 @@ module MCP # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. def list_roots(related_request_id: nil, timeout: nil) - @server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:roots) @@ -159,7 +158,6 @@ module MCP # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider # APIs instead. def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs) - @server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2) warn_unassociated_request(__method__, related_request_id) params = @server.build_sampling_params(client_capabilities, **kwargs) @@ -274,8 +272,6 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil, related_request_id: nil) - @server.send(:warn_if_deprecated_protocol_feature, :logging, session: self, uplevel: 2) - # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level # does not apply (SEP-2575). diff --git a/test/initialize_params_test_helper.rb b/test/initialize_params_test_helper.rb index 6e6d6b2..ccee933 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 7d0d859..87cdb55 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1630,6 +1630,52 @@ module MCP client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) end + def test_adopts_a_counter_offered_protocol_version_from_the_handshake + # A server may answer `initialize` with a version other than the one offered, and everything + # after the handshake speaks the answer rather than the offer. This server counter-offers + # a handshake version to clients asking `initialize` for a modern one, which only resolves + # anything because clients behave this way. + counter_offered = "2025-06-18" + refute_equal( + counter_offered, + MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + "the counter-offer has to differ from the offer for this to test anything", + ) + + offered = nil + stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" + body["method"] == "initialize" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { result: { protocolVersion: counter_offered } }.to_json, + ) + stub_notification + + client.connect + + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + assert_equal(counter_offered, client.protocol_version) + + # Scoped to the body as well: `notifications/initialized` already went out under + # the adopted version, and a header-only stub would count that too. + header_stub = stub_request(:post, url).with( + headers: { "MCP-Protocol-Version" => counter_offered }, + ) do |req| + JSON.parse(req.body)["method"] == "tools/list" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + + assert_requested(header_stub) + end + def test_does_not_send_protocol_version_header_before_initialize stub_request(:post, url) .with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") } @@ -1884,14 +1930,14 @@ module MCP .with do |req| body = JSON.parse(req.body) body["method"] == "initialize" && - body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION && + body["params"]["protocolVersion"] == MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION && body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } && body["params"]["capabilities"] == {} end .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json, + body: { result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json, ) client.connect @@ -1927,9 +1973,31 @@ module MCP assert_requested(notification_stub) end - def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28 - notification_stub = stub_notification + def test_connect_offers_the_latest_handshake_version_by_default + # The handshake negotiates legacy versions only (SEP-2575 era model); modern versions are + # selected via `mode: :modern`/`:auto`, so the default offer is the latest handshake version. + offered = nil + init_stub = stub_request(:post, url).with do |req| + body = JSON.parse(req.body) + offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" + body["method"] == "initialize" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + stub_notification + client.connect + + assert_requested(init_stub) + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + end + + def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result + # A server answering `initialize` with a modern version is refused like an unknown one: + # the handshake settles on a legacy version by definition, and the TypeScript and Python clients + # refuse a modern counter-offer the same way. init_stub = stub_request(:post, url) .with { |req| JSON.parse(req.body)["method"] == "initialize" } .to_return( @@ -1938,12 +2006,20 @@ module MCP body: { result: { protocolVersion: "2026-07-28" } }.to_json, ) - assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do - client.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) + error = assert_raises(RequestHandlerError) do + client.connect end + assert_includes(error.message, "2026-07-28") assert_requested(init_stub) - assert_requested(notification_stub) + end + + def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake + error = assert_raises(ArgumentError) do + client.connect(protocol_version: "2026-07-28", mode: :legacy) + end + + assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") end def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older @@ -2167,6 +2243,37 @@ module MCP assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") } end + def test_connect_auto_propagates_the_discovery_failure_for_an_explicitly_modern_version + # An explicitly requested modern version is never downgraded by the fallback: + # the legacy handshake cannot negotiate it, so the probe's failure is the real answer. + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + ) + + error = assert_raises(RequestHandlerError) do + client.connect(mode: :auto, protocol_version: "2026-07-28") + end + + assert_includes(error.message, "no mutually supported modern protocol version") + refute_predicate(client, :connected?) + end + + def test_connect_modern_warns_for_deprecated_capabilities + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every + # modern connection speaks. + stub_discover + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + client.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_predicate(client, :modern?) + end + def test_reconnect_after_close stub_initialize stub_notification diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 4a04bfe..3b16040 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -818,7 +818,56 @@ module MCP stdout_write.close end - def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28 + def test_connect_adopts_a_counter_offered_protocol_version + # A server may answer `initialize` with a version other than the one offered, and the connection + # then speaks the answer rather than the offer. This server counter-offers a handshake version + # to clients asking `initialize` for a modern one, which only resolves anything because + # clients behave this way. + counter_offered = "2025-06-18" + refute_equal( + counter_offered, + MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + "the counter-offer has to differ from the offer for this to test anything", + ) + + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + offered = nil + + server_thread = Thread.new do + init_request = JSON.parse(stdin_read.gets) + offered = init_request.dig("params", "protocolVersion") + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: counter_offered }, + )) + stdout_write.flush + stdin_read.gets + end + + transport.connect + + assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) + assert_equal(counter_offered, transport.protocol_version) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result + # A server answering `initialize` with a modern version is refused like an unknown one: + # the handshake settles on a legacy version by definition (SEP-2575 era model), + # and the TypeScript and Python clients refuse a modern counter-offer the same way. stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe stderr_read, _ = IO.pipe @@ -836,12 +885,14 @@ module MCP result: { protocolVersion: "2026-07-28" }, )) stdout_write.flush - stdin_read.gets end - assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + error = assert_raises(RequestHandlerError) do transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) end + + assert_includes(error.message, "2026-07-28") + refute_predicate(transport, :connected?) ensure server_thread.join stdin_read.close @@ -850,6 +901,19 @@ module MCP stdout_write.close end + def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake + # Validated before the child process is spawned: a pure argument error must not leave + # an orphaned server process behind. + Open3.expects(:popen3).never + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + error = assert_raises(ArgumentError) do + transport.connect(protocol_version: "2026-07-28", mode: :legacy) + end + + assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") + end + def test_connect_raises_on_jsonrpc_error_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -1498,6 +1562,43 @@ module MCP end end + def test_connect_modern_warns_for_deprecated_capabilities + # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks. + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + discover_request = JSON.parse(stdin_read.gets) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: discover_request["id"], + result: { + supportedVersions: ["2026-07-28"], + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + ttlMs: 0, + cacheScope: "private", + }, + })) + stdout_write.flush + end + + assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do + transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) + end + + assert_predicate(transport, :modern?) + ensure + server_thread&.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| + io.close unless io.closed? + end + end + def test_connect_auto_falls_back_to_the_legacy_handshake stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 6aa10cc..058bc19 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -36,15 +36,21 @@ module MCP assert_equal test_context, reported_context end - # https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15 test "initializes with default protocol version" do + # The unset pin reads as the version the handshake settles on, which is also the only kind of + # value the writer accepts: reading back a version that cannot be set again would break code + # that round-trips the setting. config = Configuration.new - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version + refute_predicate config, :protocol_version? + assert_nothing_raised { Configuration.new(protocol_version: config.protocol_version) } end - test "uses the draft protocol version when protocol_version is set to nil" do + test "uses the default protocol version when protocol_version is set to nil" do config = Configuration.new(protocol_version: nil) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version end test "raises ArgumentError when setting the draft protocol version" do @@ -54,7 +60,7 @@ module MCP Configuration.new(protocol_version: "DRAFT-2025-v3") end - assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "raises ArgumentError when protocol_version is not a supported protocol version" do @@ -63,7 +69,7 @@ module MCP custom_version = "2025-03-27" config.protocol_version = custom_version end - assert_equal("protocol_version must be 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) + assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end test "exposes the SEP-2575 modern protocol versions" do @@ -73,11 +79,20 @@ module MCP refute Configuration.modern_protocol_version?("2025-11-25") end - test "accepts 2026-07-28 as the protocol version" do - # 2026-07-28 serves both lifecycles of the dual-era model (SEP-2575), so it is negotiable through - # the legacy `initialize` handshake and settable as the fallback version. - config = Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) - assert_equal "2026-07-28", config.protocol_version + test "rejects a modern version as the protocol version pin" do + # The pin scopes the `initialize` handshake; a modern version has no handshake to pin + # (its version rides every request in `_meta`), so accepting it would configure nothing. + # The message names that reason instead of listing the accepted values, because this is + # the error an upgrade from a release that accepted the value lands on. + exception = assert_raises(ArgumentError) do + Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) + end + + assert_includes(exception.message, "is a modern protocol version and cannot be pinned here") + assert_includes(exception.message, "remove the setting") + + config = Configuration.new + assert_raises(ArgumentError) { config.protocol_version = "2026-07-28" } end test "raises ArgumentError when protocol_version is not a boolean value" do diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index eef0a7c..683e2ae 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -563,15 +563,15 @@ module MCP assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) end - test "initialize negotiating 2026-07-28 still locks the legacy era" do - # 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through - # the legacy handshake locks `:legacy`, so a later modern envelope is still rejected. + test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do + # Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on + # the legacy lifecycle it selected, so a later modern envelope is still rejected. responses = run_transport_session([ initialize_request(id: 1, protocol_version: "2026-07-28"), modern_tools_list_request(id: 2), ]) - assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion) + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, responses[0].dig(:result, :protocolVersion) assert_equal :legacy, session_era assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 3085356..26b8906 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 @@ module MCP body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] assert_equal "123", body["id"] - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do @@ -1902,7 +1902,9 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] body = JSON.parse(response[2][0]) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + # The body's requested version drives negotiation (not the older header); + # the default modern request is counter-offered the latest handshake version. + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do @@ -2027,7 +2029,9 @@ module MCP { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, ) init_response = @transport.handle_request(init_request) - assert_equal "2026-07-28", JSON.parse(init_response[2][0])["result"]["protocolVersion"] + # The handshake never negotiates a modern version (SEP-2575 era model); + # the session proceeds on the counter-offered 2025-11-25. + assert_equal "2025-11-25", JSON.parse(init_response[2][0])["result"]["protocolVersion"] session_id = init_response[1]["mcp-session-id"] request = create_rack_request( @@ -5784,9 +5788,10 @@ module MCP assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) end - test "sessionless POST initialize with the dual-era header stays legacy and negotiates 2026-07-28" do + test "sessionless POST initialize with the dual-era header stays legacy and is counter-offered" do # The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is - # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle. + # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle + # and is counter-offered the latest handshake version (SEP-2575 era model). request = create_rack_request( "POST", "/", @@ -5797,10 +5802,16 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] refute_nil response[1]["mcp-session-id"] - assert_equal "2026-07-28", JSON.parse(response[2][0]).dig("result", "protocolVersion") + assert_equal "2025-11-25", JSON.parse(response[2][0]).dig("result", "protocolVersion") end test "session-bound POST with the dual-era header stays on the legacy path" do + # A deliberately lenient reading. The header names a version the handshake refused to + # negotiate, so it disagrees with the session; the session wins and the request is served + # at the negotiated revision, unstamped. Rejecting instead would read the spec's + # "invalid or unsupported MCP-Protocol-Version MUST be 400" as covering a version this + # server does in fact support (through the modern lifecycle), and would break a client + # over what is a SHOULD on its side: sending the negotiated version rather than its own. session_id = initialize_test_session response = @transport.handle_request(create_rack_request( @@ -5815,6 +5826,13 @@ module MCP )) assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = sse_events(io).first + + # No `resultType`: the header claim does not move the session onto the modern wire. + refute(body["result"].key?("resultType")) end test "session-bound DELETE with the dual-era header terminates the legacy session" do diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index 9b1908a..a91bf1a 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -304,7 +304,7 @@ module MCP test "initialize request cannot be cancelled" do init_params = { - protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, + protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, clientInfo: { name: "test", version: "1.0" }, capabilities: {}, } diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index 9e51a03..ffeb3a8 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -85,49 +85,25 @@ module MCP assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] end - test "#notify_log_message warns when configured protocol version is 2026-07-28" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2026-07-28"), - ) + test "#notify_log_message does not warn on any server configuration" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings; + # the notification itself still goes out (or is suppressed below the configured level). + server = Server.new(name: "test_server", version: "1.0.0") mock_transport = MockTransport.new(server) server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do server.notify_log_message(data: { error: "Connection Failed" }, level: "error") end assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] - end - test "#notify_log_message warns when configured protocol version is 2026-07-28 without transport" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2026-07-28"), - ) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do - server.notify_log_message(data: { error: "Connection Failed" }, level: "error") - end - end - - test "#notify_log_message warns when configured protocol version is 2026-07-28 below configured level" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2026-07-28"), - ) - mock_transport = MockTransport.new(server) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do server.notify_log_message(data: { message: "test" }, level: "info") end - assert_empty mock_transport.notifications + assert_equal 1, mock_transport.notifications.size end test "#notify_log_message does not warn when configured protocol version is older" do @@ -144,7 +120,9 @@ module MCP end end - test "ServerSession#notify_log_message warns when negotiated protocol version is 2026-07-28 below configured level" do + test "ServerSession#notify_log_message does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), where logging + # is not deprecated; the below-configured-level suppression still applies. server = Server.new(name: "test_server", version: "1.0.0") mock_transport = MockTransport.new(server) session = ServerSession.new(server: server, transport: mock_transport) @@ -163,7 +141,7 @@ module MCP ) session.configure_logging(MCP::LoggingMessageNotification.new(level: "error")) - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do session.notify_log_message(data: { message: "test" }, level: "info") end diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index 283fbdd..39c4e8f 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -64,7 +64,9 @@ module MCP assert callback_called end - test "notifications/roots/list_changed warns when negotiated protocol version is 2026-07-28" do + test "notifications/roots/list_changed does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), + # where roots is not deprecated. server = Server.new(name: "test", version: "1.0") server.handle({ jsonrpc: "2.0", @@ -77,7 +79,7 @@ module MCP }, }) - assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + assert_no_deprecation_warning do server.handle({ jsonrpc: "2.0", method: "notifications/roots/list_changed", @@ -176,13 +178,14 @@ module MCP assert_silent { session.list_roots(related_request_id: "req-1") } end - test "ServerSession#list_roots warns when negotiated protocol version is 2026-07-28 and client lacks roots" do - session = ServerSession.new(server: @server, transport: @mock_transport) + test "ServerSession#list_roots raises without a warning when the client lacks roots" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. + session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) session.store_client_info(client: { name: "test-client" }, capabilities: {}) - session.mark_initialized!(protocol_version: "2026-07-28") error = nil - assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + assert_no_deprecation_warning do error = assert_raises(RuntimeError) do session.list_roots(related_request_id: "req-1") end @@ -267,7 +270,9 @@ module MCP assert_equal("No active stream for roots/list request.", error.message) end - test "ServerSession#list_roots warns when session negotiated protocol version is 2026-07-28" do + test "ServerSession#list_roots does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), + # where roots is not deprecated; the request itself still goes out. server = Server.new(name: "test", version: "1.0") transport = MockTransport.new(server) @@ -286,7 +291,7 @@ module MCP session: session, ) - assert_deprecation_warning(/MCP Roots .*2026-07-28/) do + assert_no_deprecation_warning do session.list_roots(related_request_id: "req-1") end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index b2c84f5..c5d0d59 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -64,11 +64,14 @@ module MCP assert_equal "Response from LLM", result[:content][:text] end - test "create_sampling_message warns when session negotiated protocol version is 2026-07-28" do - @session.mark_initialized!(protocol_version: "2026-07-28") + test "create_sampling_message does not warn on a modern-era session" do + # A modern pin is rejected at configuration time and the handshake never lands on + # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. + session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) + session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} }) - assert_deprecation_warning(/MCP Sampling .*2026-07-28/) do - @session.create_sampling_message( + assert_no_deprecation_warning do + session.create_sampling_message( related_request_id: "req-1", messages: [{ role: "user", content: { type: "text", text: "Hello" } }], max_tokens: 100, @@ -126,24 +129,6 @@ module MCP assert_equal("Client does not support sampling.", error.message) end - test "create_sampling_message warns when negotiated protocol version is 2026-07-28 and client lacks sampling" do - @session.store_client_info(client: { name: "test-client" }, capabilities: {}) - @session.mark_initialized!(protocol_version: "2026-07-28") - - error = nil - assert_deprecation_warning(/MCP Sampling .*2026-07-28/) do - error = assert_raises(RuntimeError) do - @session.create_sampling_message( - related_request_id: "req-1", - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - end - - assert_equal("Client does not support sampling.", error.message) - end - test "create_sampling_message raises error when tools used but client lacks sampling.tools" do error = assert_raises(RuntimeError) do @session.create_sampling_message( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 480827e..6440815 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -430,9 +430,9 @@ module MCP refute_nil response[:result] end - test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do - # Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope: - # the handshake still belongs to the legacy lifecycle and locks that era. + test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do + # Per the SEP-2575 era model, the handshake never lands on a modern version: the client is + # counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected. session = ServerSession.new(server: @server, transport: mock) response = @server.handle( @@ -445,7 +445,7 @@ module MCP session: session, ) - assert_equal "2026-07-28", response.dig(:result, :protocolVersion) + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response.dig(:result, :protocolVersion) assert_equal :legacy, session.era end @@ -724,7 +724,7 @@ module MCP jsonrpc: "2.0", id: 1, result: { - protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, + protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, capabilities: { prompts: { listChanged: true }, resources: { listChanged: true }, @@ -916,7 +916,7 @@ module MCP params: initialize_params(protocolVersion: "1999-01-01"), }) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "instrumentation data does not include client key when no clientInfo provided" do @@ -1715,7 +1715,10 @@ module MCP refute response.key?(:error) end - test "#configure_logging_level warns when negotiated protocol version is 2026-07-28" do + test "#configure_logging_level does not warn after a modern initialize request is counter-offered" do + # `initialize` asking for 2026-07-28 lands on 2025-11-25, where logging is not deprecated, + # so no warning fires. On the modern lifecycle itself `logging/setLevel` is a removed method + # and never reaches this handler. server = Server.new(tools: [TestTool]) server.handle( { @@ -1731,7 +1734,7 @@ module MCP ) response = nil - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do + assert_no_deprecation_warning do response = server.handle( { jsonrpc: "2.0", @@ -1827,35 +1830,6 @@ module MCP assert_includes response[:error][:data], "Server does not support logging" end - test "#configure_logging_level warns when configured protocol version is 2026-07-28 and server lacks logging capability" do - server = Server.new( - tools: [TestTool], - configuration: Configuration.new(protocol_version: "2026-07-28"), - capabilities: { - tools: { listChanged: true }, - prompts: { listChanged: true }, - resources: { listChanged: true }, - }, - ) - - response = nil - assert_deprecation_warning(/MCP Logging .*2026-07-28/) do - response = server.handle( - { - jsonrpc: "2.0", - id: 1, - method: "logging/setLevel", - params: { - level: "debug", - }, - }, - ) - end - - assert_equal(-32603, response[:error][:code]) - assert_includes response[:error][:data], "Server does not support logging" - end - test "#handle method with missing required top-level capability returns an error" do @server.capabilities = {} @@ -2207,7 +2181,7 @@ module MCP assert_equal local_exception_reporter, server.configuration.exception_reporter end - test "server uses default protocol version when not configured" do + test "server uses the latest handshake version when not configured" do request = { jsonrpc: "2.0", method: "initialize", @@ -2216,7 +2190,7 @@ module MCP } response = @server.handle(request) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "server response does not include optional parameters when configured" do @@ -2606,7 +2580,7 @@ module MCP assert_equal "2025-06-18", response[:result][:protocolVersion] end - test "server falls back to default version when client requests unsupported version" do + test "server falls back to the latest handshake version when client requests unsupported version" do server = Server.new(name: "test_server") request = { @@ -2617,10 +2591,13 @@ module MCP } response = server.handle(request) - assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end - test "server negotiates 2026-07-28 when the client requests it via initialize" do + test "server counter-offers 2025-11-25 when the client requests 2026-07-28 via initialize" do + # Per the SEP-2575 era model, `initialize` negotiates legacy versions only: a modern version carries + # its own version on every request and has no handshake at all. The TypeScript and Python servers + # answer the same way. server = Server.new(name: "test_server") request = { @@ -2631,7 +2608,7 @@ module MCP } response = server.handle(request) - assert_equal "2026-07-28", response[:result][:protocolVersion] + assert_equal "2025-11-25", response[:result][:protocolVersion] end test "server removes description and icons from server_info when negotiating to 2025-06-18" do @@ -4356,6 +4333,23 @@ module MCP end end + test "results after a counter-offered modern initialize carry no resultType" do + # The scenario from issue #512: a client asking `initialize` for 2026-07-28 lands on + # 2025-11-25, where `resultType` does not exist, so its absence is the correct shape + # (clients MUST read an absent `resultType` as "complete" on that revision). + server = Server.new(name: "result_type_test", tools: [result_type_tool]) + session = ServerSession.new(server: server, transport: mock) + + init_response = server.handle( + { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28") }, + session: session, + ) + response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 2 }, session: session) + + assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, init_response.dig(:result, :protocolVersion) + refute response[:result].key?(:resultType) + end + test "server/discover carries resultType complete" do server = Server.new(name: "result_type_test", tools: [result_type_tool])