Skip to content

Commit ddfbfad

Browse files
committed
Add client-level connect for initialize handshake
1 parent a2706cc commit ddfbfad

5 files changed

Lines changed: 471 additions & 4 deletions

File tree

docs/building-clients.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
6262
```ruby
6363
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
6464
client = MCP::Client.new(transport: http_transport)
65+
client.connect
6566

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

78+
### Handshake
79+
80+
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
81+
82+
```ruby
83+
client.connect
84+
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
85+
86+
client.connected? # => true
87+
client.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 HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
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.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,48 @@ def initialize(transport:)
5959
# So keeping it public
6060
attr_reader :transport
6161

62+
# The server's `InitializeResult` (protocol version, capabilities, server info,
63+
# instructions), as reported by the transport after a successful `connect`.
64+
# Returns `nil` before `connect`, after `close`, or when the transport manages
65+
# the handshake implicitly and does not expose it (e.g. stdio).
66+
def server_info
67+
transport.server_info if transport.respond_to?(:server_info)
68+
end
69+
70+
# Performs the MCP `initialize` handshake by delegating to the transport when
71+
# it exposes a `connect` method (e.g. `MCP::Client::HTTP`). Returns the
72+
# server's `InitializeResult`.
73+
#
74+
# When the transport does not respond to `:connect` (e.g. `MCP::Client::Stdio`
75+
# manages the handshake implicitly on the first request), this is a no-op and
76+
# returns `nil`.
77+
#
78+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
79+
# @param protocol_version [String, nil] Protocol version to offer.
80+
# @param capabilities [Hash] Capabilities advertised by the client.
81+
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
82+
# does not expose an explicit handshake.
83+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
84+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
85+
return unless transport.respond_to?(:connect)
86+
87+
transport.connect(
88+
client_info: client_info,
89+
protocol_version: protocol_version,
90+
capabilities: capabilities,
91+
)
92+
end
93+
94+
# Returns true once `connect` has completed the handshake on transports that
95+
# expose connection state. Transports that manage the handshake implicitly
96+
# (e.g. stdio) always report `true`, since the first request will initialize
97+
# on demand.
98+
def connected?
99+
return transport.connected? if transport.respond_to?(:connected?)
100+
101+
true
102+
end
103+
62104
# Returns a single page of tools from the server.
63105
#
64106
# @param cursor [String, nil] Cursor from a previous page response.

lib/mcp/client/http.rb

Lines changed: 99 additions & 2 deletions
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,102 @@ 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+
clear_session
69+
error = response["error"]
70+
raise RequestHandlerError.new(
71+
"Server initialization failed: #{error["message"]}",
72+
{ method: MCP::Methods::INITIALIZE },
73+
error_type: :internal_error,
74+
)
75+
end
76+
77+
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
78+
clear_session
79+
raise RequestHandlerError.new(
80+
"Server initialization failed: missing result in response",
81+
{ method: MCP::Methods::INITIALIZE },
82+
error_type: :internal_error,
83+
)
84+
end
85+
86+
@server_info = response["result"]
87+
negotiated_protocol_version = @server_info["protocolVersion"]
88+
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
89+
clear_session
90+
raise RequestHandlerError.new(
91+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
92+
{ method: MCP::Methods::INITIALIZE },
93+
error_type: :internal_error,
94+
)
95+
end
96+
97+
begin
98+
send_request(request: {
99+
jsonrpc: JsonRpcHandler::Version::V2_0,
100+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
101+
})
102+
rescue StandardError
103+
clear_session
104+
raise
105+
end
106+
107+
@connected = true
108+
@server_info
109+
end
110+
111+
# Returns true once `connect` has completed the full handshake
112+
# (`initialize` response received and `notifications/initialized` sent).
113+
# Returns false before the first handshake and after `close`.
114+
def connected?
115+
@connected
24116
end
25117

26118
# Sends a JSON-RPC request and returns the parsed response body.
@@ -105,7 +197,10 @@ def send_request(request:)
105197
# session state is cleared either way.
106198
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
107199
def close
108-
return unless @session_id
200+
unless @session_id
201+
clear_session
202+
return
203+
end
109204

110205
begin
111206
client.delete("", nil, session_headers)
@@ -159,6 +254,8 @@ def capture_session_info(method, response, body)
159254
def clear_session
160255
@session_id = nil
161256
@protocol_version = nil
257+
@server_info = nil
258+
@connected = false
162259
end
163260

164261
def require_faraday!

0 commit comments

Comments
 (0)