Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`:

Expand All@@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/building-clients.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.
Expand Down
28 changes: 21 additions & 7 deletions lib/mcp/client/http.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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}",
Expand All@@ -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,
Expand DownExpand Up@@ -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
Expand All@@ -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

Expand Down
29 changes: 21 additions & 8 deletions lib/mcp/client/stdio.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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 }
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand All@@ -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,
Expand DownExpand Up@@ -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
Expand All@@ -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

Expand Down
56 changes: 47 additions & 9 deletions lib/mcp/configuration.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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?
Expand DownExpand Up@@ -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)
Expand Down
9 changes: 9 additions & 0 deletions lib/mcp/protocol_deprecations.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
Loading