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
2 changed files with 472 additions and 207 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue