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:
commit
a62e56ef48
11 changed files with 472 additions and 207 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue