Add client-level connect for initialize handshake

This commit is contained in:
Ates Goral 2026-05-01 20:14:33 -04:00
commit ddfbfad81e
2 changed files with 471 additions and 4 deletions

View file

@ -633,6 +633,25 @@ module MCP
assert_nil(client.session_id)
end
def test_close_clears_stateless_connection_state
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
)
stub_notification
client.connect
client.close
assert_not_requested(:delete, url)
refute_predicate(client, :connected?)
assert_nil(client.protocol_version)
assert_nil(client.server_info)
end
def test_close_tolerates_405_response
initialize_session
stub_request(:delete, url).to_return(status: 405)
@ -695,6 +714,213 @@ module MCP
assert_requested(:delete, url, times: 1)
end
def test_connect_performs_initialize_handshake
init_stub = stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" },
body: {
result: {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "test-server", version: "1.0" },
},
}.to_json,
)
notification_stub = stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
.to_return(status: 202, body: "")
result = client.connect
assert_requested(init_stub)
assert_requested(notification_stub)
assert_equal("2025-11-25", result["protocolVersion"])
assert_equal({ "tools" => {} }, result["capabilities"])
assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"])
end
def test_connect_caches_server_info
stub_initialize
stub_notification
client.connect
assert_equal("2025-11-25", client.server_info["protocolVersion"])
end
def test_connect_uses_default_client_info_and_protocol_version
notification_stub = stub_notification
init_stub = stub_request(:post, url)
.with do |req|
body = JSON.parse(req.body)
body["method"] == "initialize" &&
body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_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,
)
client.connect
assert_requested(init_stub)
assert_requested(notification_stub)
end
def test_connect_accepts_custom_parameters
notification_stub = stub_notification
init_stub = stub_request(:post, url)
.with do |req|
body = JSON.parse(req.body)
body["method"] == "initialize" &&
body["params"]["protocolVersion"] == "2025-03-26" &&
body["params"]["clientInfo"] == { "name" => "my-app", "version" => "9.9" } &&
body["params"]["capabilities"] == { "roots" => { "listChanged" => true } }
end
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { protocolVersion: "2025-03-26" } }.to_json,
)
client.connect(
client_info: { name: "my-app", version: "9.9" },
protocol_version: "2025-03-26",
capabilities: { roots: { listChanged: true } },
)
assert_requested(init_stub)
assert_requested(notification_stub)
end
def test_connect_is_idempotent
init_stub = stub_initialize
notification_stub = stub_notification
first_result = client.connect
second_result = client.connect
assert_same(first_result, second_result)
assert_requested(init_stub, times: 1)
assert_requested(notification_stub, times: 1)
end
def test_connect_raises_on_jsonrpc_error_response
stub_request(:post, url).to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json,
)
error = assert_raises(RequestHandlerError) do
client.connect
end
assert_includes(error.message, "Unsupported protocol version")
refute_predicate(client, :connected?)
assert_nil(client.session_id)
assert_nil(client.protocol_version)
assert_nil(client.server_info)
end
def test_connect_raises_on_missing_result
stub_request(:post, url).to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
body: { jsonrpc: "2.0", id: "x" }.to_json,
)
error = assert_raises(RequestHandlerError) do
client.connect
end
assert_includes(error.message, "missing result in response")
refute_predicate(client, :connected?)
assert_nil(client.session_id)
assert_nil(client.protocol_version)
assert_nil(client.server_info)
end
def test_connect_raises_on_unsupported_negotiated_protocol_version
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
body: { result: { protocolVersion: "2099-01-01" } }.to_json,
)
error = assert_raises(RequestHandlerError) do
client.connect
end
assert_includes(error.message, 'unsupported protocol version "2099-01-01"')
refute_predicate(client, :connected?)
assert_nil(client.session_id)
assert_nil(client.protocol_version)
assert_nil(client.server_info)
end
def test_connect_clears_session_when_initialized_notification_fails
stub_initialize
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
.to_return(status: 500)
assert_raises(RequestHandlerError) do
client.connect
end
refute_predicate(client, :connected?)
assert_nil(client.session_id)
assert_nil(client.protocol_version)
assert_nil(client.server_info)
end
def test_connected_lifecycle
refute_predicate(client, :connected?)
stub_initialize
stub_notification
client.connect
assert_predicate(client, :connected?)
stub_request(:delete, url).to_return(status: 200)
client.close
refute_predicate(client, :connected?)
end
def test_reconnect_after_close
stub_initialize
stub_notification
client.connect
stub_request(:delete, url).to_return(status: 200)
client.close
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" },
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
)
client.connect
assert_predicate(client, :connected?)
assert_equal("s2", client.session_id)
end
def test_close_allows_reinitializing_a_fresh_session
initialize_session
stub_request(:delete, url).to_return(status: 200)
@ -732,6 +958,22 @@ module MCP
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
end
def stub_initialize
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
)
end
def stub_notification
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
.to_return(status: 202, body: "")
end
def stub_request(method, url)
WebMock.stub_request(method, url)
end

View file

@ -5,6 +5,77 @@ require "securerandom"
module MCP
class ClientTest < Minitest::Test
def test_connect_delegates_to_transport_and_forwards_keyword_args
transport = mock
init_result = {
"protocolVersion" => "2025-11-25",
"capabilities" => { "tools" => {} },
"serverInfo" => { "name" => "test-server", "version" => "1.0" },
}
transport.expects(:connect).with(
client_info: { name: "my-app", version: "1.0" },
protocol_version: "2025-11-25",
capabilities: { roots: {} },
).returns(init_result).once
result = Client.new(transport: transport).connect(
client_info: { name: "my-app", version: "1.0" },
protocol_version: "2025-11-25",
capabilities: { roots: {} },
)
assert_equal(init_result, result)
end
def test_connect_passes_nil_defaults_to_transport
transport = mock
transport.expects(:connect)
.with(client_info: nil, protocol_version: nil, capabilities: {})
.returns({ "protocolVersion" => "2025-11-25" }).once
Client.new(transport: transport).connect
end
def test_connect_is_noop_when_transport_does_not_respond_to_connect
transport = mock
transport.stubs(:respond_to?).with(:connect).returns(false)
transport.stubs(:respond_to?).with(:server_info).returns(false)
client = Client.new(transport: transport)
assert_nil(client.connect)
assert_nil(client.server_info)
end
def test_connected_delegates_to_transport_when_supported
transport = mock
transport.expects(:connected?).returns(true)
assert_predicate(Client.new(transport: transport), :connected?)
end
def test_connected_returns_true_when_transport_does_not_respond_to_connected
transport = mock
transport.stubs(:respond_to?).with(:connected?).returns(false)
assert_predicate(Client.new(transport: transport), :connected?)
end
def test_server_info_delegates_to_transport_when_supported
transport = mock
init_result = { "protocolVersion" => "2025-11-25" }
transport.expects(:server_info).returns(init_result)
assert_equal(init_result, Client.new(transport: transport).server_info)
end
def test_server_info_returns_nil_when_transport_does_not_expose_it
transport = mock
transport.stubs(:respond_to?).with(:server_info).returns(false)
assert_nil(Client.new(transport: transport).server_info)
end
def test_tools_sends_request_to_transport_and_returns_tools_array
transport = mock
mock_response = {