Skip to content

Commit d6a368a

Browse files
committed
Require calling MCP::Client#connect before sending requests on stdio transport
## Motivation and Context The MCP specification requires an `initialize` request followed by `notifications/initialized` before any other interaction. Issue modelcontextprotocol#334 makes that handshake explicit on the stdio client, matching the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`). The change was staged to avoid a hard break. Phase 1 (modelcontextprotocol#336) added `MCP::Client::Stdio#connect` while keeping an implicit-init compatibility shim on the first `send_request`. Phase 2 (modelcontextprotocol#338) emitted a deprecation warning when that shim ran. The warning has shipped since 0.16.0, so this final phase removes the shim. ## How Has This Been Tested? `send_request` now raises a `RuntimeError` when `connect` has not been called, and no longer auto-starts the subprocess. `connect` is the sole entry point that spawns the process and performs the handshake, which avoids leaving an orphaned subprocess behind. The stdio test suite was reworked to call `connect` explicitly, a regression test was added for the new guard, and `rake test` passes (1145 runs, 0 failures). ## Breaking Changes Stdio clients that send requests without calling `MCP::Client#connect` now raise a `RuntimeError` instead of silently initializing. Call `client.connect` before the first request. Closes modelcontextprotocol#334
1 parent 0694e38 commit d6a368a

2 files changed

Lines changed: 34 additions & 214 deletions

File tree

lib/mcp/client/stdio.rb

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,7 @@ def connect(client_info: nil, protocol_version: nil, capabilities: {})
128128
@server_info
129129
end
130130

131-
# Returns true once `connect` (or the implicit handshake on the first
132-
# `send_request`) has completed. Returns false before the handshake
133-
# and after `close`.
131+
# Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
134132
def connected?
135133
@initialized
136134
end
@@ -140,11 +138,7 @@ def connected?
140138
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
141139
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
142140
def send_request(request:)
143-
start unless @started
144-
unless @initialized
145-
warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
146-
connect
147-
end
141+
raise "MCP::Client#connect must be called before sending requests." unless @initialized
148142

149143
@write_mutex.synchronize do
150144
write_message(request)

test/mcp/client/stdio_test.rb

Lines changed: 32 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@
99
module MCP
1010
class Client
1111
class StdioTest < Minitest::Test
12-
IMPLICIT_CONNECT_DEPRECATION_WARNING =
13-
/Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated\. Use `MCP::Client#connect` before sending requests instead\./.freeze
12+
def test_send_request_raises_when_connect_not_called
13+
Open3.expects(:popen3).never
14+
15+
transport = Stdio.new(command: "ruby", args: ["server.rb"])
16+
17+
error = assert_raises(RuntimeError) do
18+
transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" })
19+
end
20+
21+
assert_equal("MCP::Client#connect must be called before sending requests.", error.message)
22+
end
1423

1524
def test_send_request_starts_process_and_returns_response
1625
stdin_read, stdin_write = IO.pipe
@@ -59,10 +68,8 @@ def test_send_request_starts_process_and_returns_response
5968
stdout_write.flush
6069
end
6170

62-
response = nil
63-
assert_implicit_connect_deprecation_warning do
64-
response = transport.send_request(request: request)
65-
end
71+
transport.connect
72+
response = transport.send_request(request: request)
6673

6774
assert_equal("test-id", response["id"])
6875
assert_equal(1, response.dig("result", "tools").size)
@@ -76,73 +83,6 @@ def test_send_request_starts_process_and_returns_response
7683
stderr_read.close
7784
end
7885

79-
def test_send_request_initializes_session_on_first_call
80-
stdin_read, stdin_write = IO.pipe
81-
stdout_read, stdout_write = IO.pipe
82-
stderr_read, _ = IO.pipe
83-
84-
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
85-
86-
transport = Stdio.new(command: "ruby", args: ["server.rb"])
87-
88-
request = {
89-
jsonrpc: "2.0",
90-
id: "test-id",
91-
method: "tools/list",
92-
}
93-
94-
received_methods = []
95-
96-
server_thread = Thread.new do
97-
# Read initialize request
98-
init_line = stdin_read.gets
99-
init_request = JSON.parse(init_line)
100-
received_methods << init_request["method"]
101-
102-
init_response = {
103-
jsonrpc: "2.0",
104-
id: init_request["id"],
105-
result: {
106-
protocolVersion: "2025-11-25",
107-
capabilities: {},
108-
serverInfo: { name: "test-server", version: "1.0.0" },
109-
},
110-
}
111-
stdout_write.puts(JSON.generate(init_response))
112-
stdout_write.flush
113-
114-
# Read initialized notification
115-
notification_line = stdin_read.gets
116-
notification = JSON.parse(notification_line)
117-
received_methods << notification["method"]
118-
119-
# Read tools/list request
120-
tools_line = stdin_read.gets
121-
tools_request = JSON.parse(tools_line)
122-
received_methods << tools_request["method"]
123-
124-
tools_response = {
125-
jsonrpc: "2.0",
126-
id: tools_request["id"],
127-
result: { tools: [] },
128-
}
129-
stdout_write.puts(JSON.generate(tools_response))
130-
stdout_write.flush
131-
end
132-
133-
assert_implicit_connect_deprecation_warning do
134-
transport.send_request(request: request)
135-
end
136-
137-
assert_equal(["initialize", "notifications/initialized", "tools/list"], received_methods)
138-
ensure
139-
server_thread.join
140-
stdin_read.close
141-
stdin_write.close
142-
stdout_read.close
143-
stdout_write.close
144-
end
145-
14686
def test_send_request_skips_notifications
14787
stdin_read, stdin_write = IO.pipe
14888
stdout_read, stdout_write = IO.pipe
@@ -193,10 +133,8 @@ def test_send_request_skips_notifications
193133
stdout_write.flush
194134
end
195135

196-
response = nil
197-
assert_implicit_connect_deprecation_warning do
198-
response = transport.send_request(request: request)
199-
end
136+
transport.connect
137+
response = transport.send_request(request: request)
200138

201139
assert_equal("test-id", response["id"])
202140
assert_empty(response.dig("result", "tools"))
@@ -223,17 +161,8 @@ def test_send_request_raises_error_when_process_exits
223161
transport = Stdio.new(command: "ruby", args: ["server.rb"])
224162
transport.start
225163

226-
request = {
227-
jsonrpc: "2.0",
228-
id: "test-id",
229-
method: "tools/list",
230-
}
231-
232-
error = nil
233-
assert_implicit_connect_deprecation_warning do
234-
error = assert_raises(RequestHandlerError) do
235-
transport.send_request(request: request)
236-
end
164+
error = assert_raises(RequestHandlerError) do
165+
transport.connect
237166
end
238167

239168
assert_equal("Server process has exited", error.message)
@@ -283,11 +212,9 @@ def test_send_request_raises_error_on_closed_stdout
283212
stdout_write.close
284213
end
285214

286-
error = nil
287-
assert_implicit_connect_deprecation_warning do
288-
error = assert_raises(RequestHandlerError) do
289-
transport.send_request(request: request)
290-
end
215+
transport.connect
216+
error = assert_raises(RequestHandlerError) do
217+
transport.send_request(request: request)
291218
end
292219

293220
assert_equal("Server process closed stdout unexpectedly", error.message)
@@ -341,7 +268,7 @@ def test_close_resets_state
341268
stderr_write.close
342269
end
343270

344-
def test_send_request_skips_initialization_on_second_call
271+
def test_multiple_send_requests_do_not_reinitialize
345272
stdin_read, stdin_write = IO.pipe
346273
stdout_read, stdout_write = IO.pipe
347274
stderr_read, _ = IO.pipe
@@ -398,9 +325,8 @@ def test_send_request_skips_initialization_on_second_call
398325
stdout_write.flush
399326
end
400327

401-
assert_implicit_connect_deprecation_warning do
402-
transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" })
403-
end
328+
transport.connect
329+
transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" })
404330
transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" })
405331

406332
assert_equal(
@@ -464,11 +390,9 @@ def test_send_request_raises_error_on_invalid_json
464390
stdout_write.flush
465391
end
466392

467-
error = nil
468-
assert_implicit_connect_deprecation_warning do
469-
error = assert_raises(RequestHandlerError) do
470-
transport.send_request(request: request)
471-
end
393+
transport.connect
394+
error = assert_raises(RequestHandlerError) do
395+
transport.send_request(request: request)
472396
end
473397

474398
assert_equal("Failed to parse server response", error.message)
@@ -482,50 +406,6 @@ def test_send_request_raises_error_on_invalid_json
482406
stdout_write.close
483407
end
484408

485-
def test_send_request_raises_error_when_initialization_fails
486-
stdin_read, stdin_write = IO.pipe
487-
stdout_read, stdout_write = IO.pipe
488-
stderr_read, _ = IO.pipe
489-
490-
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
491-
492-
transport = Stdio.new(command: "ruby", args: ["server.rb"])
493-
494-
request = {
495-
jsonrpc: "2.0",
496-
id: "test-id",
497-
method: "tools/list",
498-
}
499-
500-
server_thread = Thread.new do
501-
# Read initialize request and return an error
502-
init_line = stdin_read.gets
503-
init_request = JSON.parse(init_line)
504-
stdout_write.puts(JSON.generate({
505-
jsonrpc: "2.0",
506-
id: init_request["id"],
507-
error: { code: -32600, message: "Invalid Request", data: "Unsupported protocol version" },
508-
}))
509-
stdout_write.flush
510-
end
511-
512-
error = nil
513-
assert_implicit_connect_deprecation_warning do
514-
error = assert_raises(RequestHandlerError) do
515-
transport.send_request(request: request)
516-
end
517-
end
518-
519-
assert_equal("Server initialization failed: Invalid Request", error.message)
520-
assert_equal(:internal_error, error.error_type)
521-
ensure
522-
server_thread.join
523-
stdin_read.close
524-
stdin_write.close
525-
stdout_read.close
526-
stdout_write.close
527-
end
528-
529409
def test_close_kills_process_on_timeout
530410
stdin_read, stdin_write = IO.pipe
531411
stdout_read, stdout_write = IO.pipe
@@ -589,11 +469,9 @@ def test_read_response_raises_error_on_timeout
589469
stdin_read.gets
590470
end
591471

592-
error = nil
593-
assert_implicit_connect_deprecation_warning do
594-
error = assert_raises(RequestHandlerError) do
595-
transport.send_request(request: request)
596-
end
472+
transport.connect
473+
error = assert_raises(RequestHandlerError) do
474+
transport.send_request(request: request)
597475
end
598476

599477
assert_equal("Timed out waiting for server response", error.message)
@@ -644,10 +522,9 @@ def test_send_request_raises_error_when_stdin_is_closed
644522
stdout_write.flush
645523
end
646524

647-
# Complete handshake with a successful request
648-
assert_implicit_connect_deprecation_warning do
649-
transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" })
650-
end
525+
transport.connect
526+
# Complete a successful request before breaking the pipe.
527+
transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" })
651528
server_thread.join
652529

653530
# Now close stdin to simulate broken pipe
@@ -713,49 +590,6 @@ def test_start_raises_error_for_invalid_command
713590
assert_instance_of(Errno::ENOENT, error.original_error)
714591
end
715592

716-
def test_send_request_raises_error_for_missing_result
717-
stdin_read, stdin_write = IO.pipe
718-
stdout_read, stdout_write = IO.pipe
719-
stderr_read, _ = IO.pipe
720-
721-
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
722-
723-
transport = Stdio.new(command: "ruby", args: ["server.rb"])
724-
725-
request = {
726-
jsonrpc: "2.0",
727-
id: "test-id",
728-
method: "tools/list",
729-
}
730-
731-
server_thread = Thread.new do
732-
# Read initialize request and return a response without result
733-
init_line = stdin_read.gets
734-
init_request = JSON.parse(init_line)
735-
stdout_write.puts(JSON.generate({
736-
jsonrpc: "2.0",
737-
id: init_request["id"],
738-
}))
739-
stdout_write.flush
740-
end
741-
742-
error = nil
743-
assert_implicit_connect_deprecation_warning do
744-
error = assert_raises(RequestHandlerError) do
745-
transport.send_request(request: request)
746-
end
747-
end
748-
749-
assert_equal("Server initialization failed: missing result in response", error.message)
750-
assert_equal(:internal_error, error.error_type)
751-
ensure
752-
server_thread.join
753-
stdin_read.close
754-
stdin_write.close
755-
stdout_read.close
756-
stdout_write.close
757-
end
758-
759593
def test_connect_performs_initialize_handshake_explicitly
760594
stdin_read, stdin_write = IO.pipe
761595
stdout_read, stdout_write = IO.pipe
@@ -1343,14 +1177,6 @@ def test_concurrent_write_message_does_not_interleave_lines
13431177

13441178
private
13451179

1346-
def assert_implicit_connect_deprecation_warning(&block)
1347-
original_verbose = $VERBOSE
1348-
$VERBOSE = false
1349-
assert_output(nil, IMPLICIT_CONNECT_DEPRECATION_WARNING, &block)
1350-
ensure
1351-
$VERBOSE = original_verbose
1352-
end
1353-
13541180
def stub_successful_connect
13551181
stdin_read, stdin_write = IO.pipe
13561182
stdout_read, stdout_write = IO.pipe

0 commit comments

Comments
 (0)