Merge pull request #516 from koic/counter_offer_legacy_version_on_initialize

Stop negotiating modern protocol versions through the `initialize` handshake
This commit is contained in:
Koichi ITO 2026-08-15 11:32:15 +09:00 • committed by GitHub
commit a62e56ef48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 472 additions and 207 deletions

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: {},
}

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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
@ -1721,7 +1721,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(
{
@ -1737,7 +1740,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",
@ -1833,35 +1836,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 = {}
@ -2213,7 +2187,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",
@ -2222,7 +2196,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
@ -2612,7 +2586,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 = {
@ -2623,10 +2597,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 = {
@ -2637,7 +2614,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
@ -4362,6 +4339,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])