Skip to content

Commit 4ebea00

Browse files
committed
Add HTTP client connect for initialize handshake
Per MCP spec, clients must send an `initialize` request followed by a `notifications/initialized` notification before issuing any other requests. The server's `InitializeResult` (protocol version, server capabilities, server info, instructions) negotiates the session for the lifetime of the connection. `MCP::Client::HTTP#connect` performs the full handshake, caches the server's `InitializeResult`, and exposes it via the `server_info` reader. `connected?` reports handshake completion. `connect` is idempotent — a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
1 parent ff3a42f commit 4ebea00

3 files changed

Lines changed: 277 additions & 3 deletions

File tree

docs/building-clients.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
6161

6262
```ruby
6363
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
64+
http_transport.connect
6465
client = MCP::Client.new(transport: http_transport)
6566

6667
tools = client.tools
@@ -74,16 +75,30 @@ response = client.call_tool(
7475
)
7576
```
7677

78+
### Handshake
79+
80+
Call `connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the transport sends an `initialize` request followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
81+
82+
```ruby
83+
http_transport.connect
84+
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
85+
86+
http_transport.connected? # => true
87+
http_transport.server_info # => cached InitializeResult
88+
```
89+
90+
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent — a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
91+
7792
### Sessions
7893

79-
After a successful `initialize` request, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes the session ID on subsequent requests. Both are exposed on the transport:
94+
After `connect` succeeds, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport:
8095

8196
```ruby
8297
http_transport.session_id # => "abc123..."
8398
http_transport.protocol_version # => "2025-11-25"
8499
```
85100

86-
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by sending a fresh `initialize` request.
101+
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
87102

88103
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
89104

lib/mcp/client/http.rb

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# frozen_string_literal: true
22

3+
require "securerandom"
4+
require_relative "../../json_rpc_handler"
5+
require_relative "../configuration"
36
require_relative "../methods"
7+
require_relative "../version"
48

59
module MCP
610
class Client
@@ -13,14 +17,86 @@ class HTTP
1317
SESSION_ID_HEADER = "Mcp-Session-Id"
1418
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
1519

16-
attr_reader :url, :session_id, :protocol_version
20+
attr_reader :url, :session_id, :protocol_version, :server_info
1721

1822
def initialize(url:, headers: {}, &block)
1923
@url = url
2024
@headers = headers
2125
@faraday_customizer = block
2226
@session_id = nil
2327
@protocol_version = nil
28+
@server_info = nil
29+
@connected = false
30+
end
31+
32+
# Performs the MCP `initialize` handshake: sends an `initialize` request
33+
# followed by the required `notifications/initialized` notification. The
34+
# server's `InitializeResult` (protocol version, capabilities, server
35+
# info, instructions) is cached on the transport and returned.
36+
#
37+
# Idempotent: a second call returns the cached `InitializeResult` without
38+
# contacting the server. After `close`, state is cleared and `connect`
39+
# will handshake again.
40+
#
41+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
42+
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
43+
# @param protocol_version [String, nil] Protocol version to offer. Defaults
44+
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
45+
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
46+
# @return [Hash] The server's `InitializeResult`.
47+
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
48+
# or a malformed result.
49+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
50+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
51+
return @server_info if connected?
52+
53+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
54+
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
55+
56+
response = send_request(request: {
57+
jsonrpc: JsonRpcHandler::Version::V2_0,
58+
id: SecureRandom.uuid,
59+
method: MCP::Methods::INITIALIZE,
60+
params: {
61+
protocolVersion: protocol_version,
62+
capabilities: capabilities,
63+
clientInfo: client_info,
64+
},
65+
})
66+
67+
if response.is_a?(Hash) && response.key?("error")
68+
error = response["error"]
69+
raise RequestHandlerError.new(
70+
"Server initialization failed: #{error["message"]}",
71+
{ method: MCP::Methods::INITIALIZE },
72+
error_type: :internal_error,
73+
)
74+
end
75+
76+
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
77+
raise RequestHandlerError.new(
78+
"Server initialization failed: missing result in response",
79+
{ method: MCP::Methods::INITIALIZE },
80+
error_type: :internal_error,
81+
)
82+
end
83+
84+
@server_info = response["result"]
85+
86+
send_request(request: {
87+
jsonrpc: JsonRpcHandler::Version::V2_0,
88+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
89+
})
90+
91+
@connected = true
92+
@server_info
93+
end
94+
95+
# Returns true once `connect` has completed the full handshake
96+
# (`initialize` response received and `notifications/initialized` sent).
97+
# Returns false before the first handshake and after `close`.
98+
def connected?
99+
@connected
24100
end
25101

26102
# Sends a JSON-RPC request and returns the parsed response body.
@@ -159,6 +235,8 @@ def capture_session_info(method, response, body)
159235
def clear_session
160236
@session_id = nil
161237
@protocol_version = nil
238+
@server_info = nil
239+
@connected = false
162240
end
163241

164242
def require_faraday!

test/mcp/client/http_test.rb

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,171 @@ def test_close_is_idempotent
695695
assert_requested(:delete, url, times: 1)
696696
end
697697

698+
def test_connect_performs_initialize_handshake
699+
init_stub = stub_request(:post, url)
700+
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
701+
.to_return(
702+
status: 200,
703+
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" },
704+
body: {
705+
result: {
706+
protocolVersion: "2025-11-25",
707+
capabilities: { tools: {} },
708+
serverInfo: { name: "test-server", version: "1.0" },
709+
},
710+
}.to_json,
711+
)
712+
713+
notification_stub = stub_request(:post, url)
714+
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
715+
.to_return(status: 202, body: "")
716+
717+
result = client.connect
718+
719+
assert_requested(init_stub)
720+
assert_requested(notification_stub)
721+
assert_equal("2025-11-25", result["protocolVersion"])
722+
assert_equal({ "tools" => {} }, result["capabilities"])
723+
assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"])
724+
end
725+
726+
def test_connect_caches_server_info
727+
stub_initialize
728+
stub_notification
729+
730+
client.connect
731+
732+
assert_equal("2025-11-25", client.server_info["protocolVersion"])
733+
end
734+
735+
def test_connect_uses_default_client_info_and_protocol_version
736+
notification_stub = stub_notification
737+
738+
init_stub = stub_request(:post, url)
739+
.with do |req|
740+
body = JSON.parse(req.body)
741+
body["method"] == "initialize" &&
742+
body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION &&
743+
body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } &&
744+
body["params"]["capabilities"] == {}
745+
end
746+
.to_return(
747+
status: 200,
748+
headers: { "Content-Type" => "application/json" },
749+
body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json,
750+
)
751+
752+
client.connect
753+
754+
assert_requested(init_stub)
755+
assert_requested(notification_stub)
756+
end
757+
758+
def test_connect_accepts_custom_parameters
759+
notification_stub = stub_notification
760+
761+
init_stub = stub_request(:post, url)
762+
.with do |req|
763+
body = JSON.parse(req.body)
764+
body["method"] == "initialize" &&
765+
body["params"]["protocolVersion"] == "2025-03-26" &&
766+
body["params"]["clientInfo"] == { "name" => "my-app", "version" => "9.9" } &&
767+
body["params"]["capabilities"] == { "roots" => { "listChanged" => true } }
768+
end
769+
.to_return(
770+
status: 200,
771+
headers: { "Content-Type" => "application/json" },
772+
body: { result: { protocolVersion: "2025-03-26" } }.to_json,
773+
)
774+
775+
client.connect(
776+
client_info: { name: "my-app", version: "9.9" },
777+
protocol_version: "2025-03-26",
778+
capabilities: { roots: { listChanged: true } },
779+
)
780+
781+
assert_requested(init_stub)
782+
assert_requested(notification_stub)
783+
end
784+
785+
def test_connect_is_idempotent
786+
init_stub = stub_initialize
787+
notification_stub = stub_notification
788+
789+
first_result = client.connect
790+
second_result = client.connect
791+
792+
assert_same(first_result, second_result)
793+
assert_requested(init_stub, times: 1)
794+
assert_requested(notification_stub, times: 1)
795+
end
796+
797+
def test_connect_raises_on_jsonrpc_error_response
798+
stub_request(:post, url).to_return(
799+
status: 200,
800+
headers: { "Content-Type" => "application/json" },
801+
body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json,
802+
)
803+
804+
error = assert_raises(RequestHandlerError) do
805+
client.connect
806+
end
807+
808+
assert_includes(error.message, "Unsupported protocol version")
809+
refute_predicate(client, :connected?)
810+
end
811+
812+
def test_connect_raises_on_missing_result
813+
stub_request(:post, url).to_return(
814+
status: 200,
815+
headers: { "Content-Type" => "application/json" },
816+
body: { jsonrpc: "2.0", id: "x" }.to_json,
817+
)
818+
819+
error = assert_raises(RequestHandlerError) do
820+
client.connect
821+
end
822+
823+
assert_includes(error.message, "missing result in response")
824+
refute_predicate(client, :connected?)
825+
end
826+
827+
def test_connected_lifecycle
828+
refute_predicate(client, :connected?)
829+
830+
stub_initialize
831+
stub_notification
832+
client.connect
833+
834+
assert_predicate(client, :connected?)
835+
836+
stub_request(:delete, url).to_return(status: 200)
837+
client.close
838+
839+
refute_predicate(client, :connected?)
840+
end
841+
842+
def test_reconnect_after_close
843+
stub_initialize
844+
stub_notification
845+
client.connect
846+
stub_request(:delete, url).to_return(status: 200)
847+
client.close
848+
849+
stub_request(:post, url)
850+
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
851+
.to_return(
852+
status: 200,
853+
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" },
854+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
855+
)
856+
857+
client.connect
858+
859+
assert_predicate(client, :connected?)
860+
assert_equal("s2", client.session_id)
861+
end
862+
698863
def test_close_allows_reinitializing_a_fresh_session
699864
initialize_session
700865
stub_request(:delete, url).to_return(status: 200)
@@ -732,6 +897,22 @@ def initialize_session
732897
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
733898
end
734899

900+
def stub_initialize
901+
stub_request(:post, url)
902+
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
903+
.to_return(
904+
status: 200,
905+
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
906+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
907+
)
908+
end
909+
910+
def stub_notification
911+
stub_request(:post, url)
912+
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
913+
.to_return(status: 202, body: "")
914+
end
915+
735916
def stub_request(method, url)
736917
WebMock.stub_request(method, url)
737918
end

0 commit comments

Comments
 (0)