Skip to content

Commit be8a5df

Browse files
authored
Merge pull request modelcontextprotocol#336 from koic/add_connect_to_stdio_client
Add client-level connect handshake to stdio transport
2 parents 3d94377 + 46b738e commit be8a5df

7 files changed

Lines changed: 581 additions & 75 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,6 +1782,9 @@ stdio_transport = MCP::Client::Stdio.new(
17821782
)
17831783
client = MCP::Client.new(transport: stdio_transport)
17841784

1785+
# Perform the MCP initialization handshake before sending any requests.
1786+
client.connect
1787+
17851788
# List available tools.
17861789
tools = client.tools
17871790
tools.each do |tool|
@@ -1822,6 +1825,9 @@ Example usage:
18221825
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
18231826
client = MCP::Client.new(transport: http_transport)
18241827

1828+
# Perform the MCP initialization handshake before sending any requests.
1829+
client.connect
1830+
18251831
# List available tools
18261832
tools = client.tools
18271833
tools.each do |tool|

docs/building-clients.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ The `MCP::Client` class provides an interface for interacting with MCP servers.
1616
- Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`)
1717
- Completion requests (`MCP::Client#complete`)
1818

19+
## Handshake
20+
21+
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. 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):
22+
23+
```ruby
24+
client.connect
25+
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
26+
27+
client.connected? # => true
28+
client.server_info # => cached InitializeResult
29+
```
30+
31+
`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.
32+
33+
This applies to both the Stdio and HTTP transports below.
34+
1935
## Stdio Transport
2036

2137
Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses:
@@ -28,6 +44,7 @@ stdio_transport = MCP::Client::Stdio.new(
2844
read_timeout: 30
2945
)
3046
client = MCP::Client.new(transport: stdio_transport)
47+
client.connect
3148

3249
tools = client.tools
3350
tools.each do |tool|
@@ -75,20 +92,6 @@ response = client.call_tool(
7592
)
7693
```
7794

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-
9295
### Sessions
9396

9497
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:

examples/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ $ ruby examples/stdio_client.rb
2828

2929
The client will automatically launch `stdio_server.rb` as a subprocess and demonstrate:
3030

31+
- Performing the MCP initialization handshake via `client.connect`
3132
- Listing and calling tools
3233
- Listing prompts
3334
- Listing and reading resources
34-
- Automatic MCP protocol initialization
3535
- Transport cleanup on exit
3636

3737
### 3. HTTP Server (`http_server.rb`)

examples/stdio_client.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
client = MCP::Client.new(transport: transport)
1414

1515
begin
16+
# Perform the MCP initialization handshake before sending any requests.
17+
client.connect
18+
1619
# List available tools
1720
puts "=== Listing tools ==="
1821
tools = client.tools

lib/mcp/client.rb

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,17 @@ def initialize(transport:)
6161

6262
# The server's `InitializeResult` (protocol version, capabilities, server info,
6363
# 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).
64+
# Returns `nil` before `connect`, after `close`, or when the transport does
65+
# not expose a cached handshake result.
6666
def server_info
6767
transport.server_info if transport.respond_to?(:server_info)
6868
end
6969

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`.
70+
# Performs the MCP `initialize` handshake by delegating to the transport
71+
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
72+
# `InitializeResult`.
7373
#
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
74+
# When the transport does not respond to `:connect`, this is a no-op and
7675
# returns `nil`.
7776
#
7877
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
@@ -91,10 +90,9 @@ def connect(client_info: nil, protocol_version: nil, capabilities: {})
9190
)
9291
end
9392

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.
93+
# Returns true once `connect` has completed the handshake on the underlying
94+
# transport. Transports that do not expose connection state are assumed
95+
# connected and return `true`.
9896
def connected?
9997
return transport.connected? if transport.respond_to?(:connected?)
10098

lib/mcp/client/stdio.rb

Lines changed: 100 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Stdio
1919
CLOSE_TIMEOUT = 2
2020
STDERR_READ_SIZE = 4096
2121

22-
attr_reader :command, :args, :env
22+
attr_reader :command, :args, :env, :server_info
2323

2424
def initialize(command:, args: [], env: nil, read_timeout: nil)
2525
@command = command
@@ -33,11 +33,108 @@ def initialize(command:, args: [], env: nil, read_timeout: nil)
3333
@stderr_thread = nil
3434
@started = false
3535
@initialized = false
36+
@server_info = nil
37+
end
38+
39+
# Performs the MCP `initialize` handshake: sends an `initialize` request
40+
# followed by the required `notifications/initialized` notification. The
41+
# server's `InitializeResult` (protocol version, capabilities, server
42+
# info, instructions) is cached on the transport and returned.
43+
#
44+
# Idempotent: a second call returns the cached `InitializeResult` without
45+
# contacting the server. After `close`, state is cleared and `connect`
46+
# will handshake again. Spawns the subprocess via `start` if it has not
47+
# been started yet.
48+
#
49+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
50+
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
51+
# @param protocol_version [String, nil] Protocol version to offer. Defaults
52+
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
53+
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
54+
# @return [Hash] The server's `InitializeResult`.
55+
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
56+
# a malformed result, or an unsupported protocol version.
57+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
58+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
59+
return @server_info if @initialized
60+
61+
start unless @started
62+
63+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
64+
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
65+
66+
init_request = {
67+
jsonrpc: JsonRpcHandler::Version::V2_0,
68+
id: SecureRandom.uuid,
69+
method: MCP::Methods::INITIALIZE,
70+
params: {
71+
protocolVersion: protocol_version,
72+
capabilities: capabilities,
73+
clientInfo: client_info,
74+
},
75+
}
76+
77+
write_message(init_request)
78+
response = read_response(init_request)
79+
80+
if response.key?("error")
81+
error = response["error"]
82+
raise RequestHandlerError.new(
83+
"Server initialization failed: #{error["message"]}",
84+
{ method: MCP::Methods::INITIALIZE },
85+
error_type: :internal_error,
86+
)
87+
end
88+
89+
unless response["result"].is_a?(Hash)
90+
raise RequestHandlerError.new(
91+
"Server initialization failed: missing result in response",
92+
{ method: MCP::Methods::INITIALIZE },
93+
error_type: :internal_error,
94+
)
95+
end
96+
97+
@server_info = response["result"]
98+
99+
negotiated_protocol_version = @server_info["protocolVersion"]
100+
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
101+
# Per spec, if the client does not support the server's returned protocol version,
102+
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
103+
# raising so a retry starts without a stale `server_info`.
104+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
105+
@server_info = nil
106+
raise RequestHandlerError.new(
107+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
108+
{ method: MCP::Methods::INITIALIZE },
109+
error_type: :internal_error,
110+
)
111+
end
112+
113+
begin
114+
notification = {
115+
jsonrpc: JsonRpcHandler::Version::V2_0,
116+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
117+
}
118+
write_message(notification)
119+
rescue StandardError
120+
@server_info = nil
121+
raise
122+
end
123+
124+
@initialized = true
125+
@server_info
126+
end
127+
128+
# Returns true once `connect` (or the implicit handshake on the first
129+
# `send_request`) has completed. Returns false before the handshake
130+
# and after `close`.
131+
def connected?
132+
@initialized
36133
end
37134

38135
def send_request(request:)
39136
start unless @started
40-
initialize_session unless @initialized
137+
connect unless @initialized
41138

42139
write_message(request)
43140
read_response(request)
@@ -98,57 +195,11 @@ def close
98195
@stderr_thread.join(CLOSE_TIMEOUT)
99196
@started = false
100197
@initialized = false
198+
@server_info = nil
101199
end
102200

103201
private
104202

105-
# The client MUST send a protocol version it supports. This SHOULD be the latest version.
106-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
107-
#
108-
# Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs:
109-
# https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175
110-
# https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495
111-
def initialize_session
112-
init_request = {
113-
jsonrpc: JsonRpcHandler::Version::V2_0,
114-
id: SecureRandom.uuid,
115-
method: MCP::Methods::INITIALIZE,
116-
params: {
117-
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
118-
capabilities: {},
119-
clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION },
120-
},
121-
}
122-
123-
write_message(init_request)
124-
response = read_response(init_request)
125-
126-
if response.key?("error")
127-
error = response["error"]
128-
raise RequestHandlerError.new(
129-
"Server initialization failed: #{error["message"]}",
130-
{ method: MCP::Methods::INITIALIZE },
131-
error_type: :internal_error,
132-
)
133-
end
134-
135-
unless response.key?("result")
136-
raise RequestHandlerError.new(
137-
"Server initialization failed: missing result in response",
138-
{ method: MCP::Methods::INITIALIZE },
139-
error_type: :internal_error,
140-
)
141-
end
142-
143-
notification = {
144-
jsonrpc: JsonRpcHandler::Version::V2_0,
145-
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
146-
}
147-
write_message(notification)
148-
149-
@initialized = true
150-
end
151-
152203
def write_message(message)
153204
ensure_running!
154205
json = JSON.generate(message)

0 commit comments

Comments
 (0)