Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Deprecated

- Warn when stdio clients rely on implicit initialization instead of `MCP::Client#connect` (#334)

## [0.15.0] - 2026-05-05

### Added
Expand Down
5 changes: 4 additions & 1 deletion lib/mcp/client/stdio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ def connected?

def send_request(request:)
start unless @started
connect unless @initialized
unless @initialized
warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
connect
end

write_message(request)
read_response(request)
Expand Down
130 changes: 112 additions & 18 deletions test/mcp/client/stdio_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
module MCP
class Client
class StdioTest < Minitest::Test
IMPLICIT_CONNECT_DEPRECATION_WARNING =
/Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated\. Use `MCP::Client#connect` before sending requests instead\./.freeze

def test_send_request_starts_process_and_returns_response
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
Expand Down Expand Up @@ -56,7 +59,10 @@ def test_send_request_starts_process_and_returns_response
stdout_write.flush
end

response = transport.send_request(request: request)
response = nil
assert_implicit_connect_deprecation_warning do
response = transport.send_request(request: request)
end

assert_equal("test-id", response["id"])
assert_equal(1, response.dig("result", "tools").size)
Expand Down Expand Up @@ -123,7 +129,9 @@ def test_send_request_initializes_session_on_first_call
stdout_write.flush
end

transport.send_request(request: request)
assert_implicit_connect_deprecation_warning do
transport.send_request(request: request)
end

assert_equal(["initialize", "notifications/initialized", "tools/list"], received_methods)
ensure
Expand Down Expand Up @@ -184,10 +192,13 @@ def test_send_request_skips_notifications
stdout_write.flush
end

response = transport.send_request(request: request)
response = nil
assert_implicit_connect_deprecation_warning do
response = transport.send_request(request: request)
end

assert_equal("test-id", response["id"])
assert_equal([], response.dig("result", "tools"))
assert_empty(response.dig("result", "tools"))
ensure
server_thread.join
stdin_read.close
Expand Down Expand Up @@ -217,8 +228,11 @@ def test_send_request_raises_error_when_process_exits
method: "tools/list",
}

error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
error = nil
assert_implicit_connect_deprecation_warning do
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
end

assert_equal("Server process has exited", error.message)
Expand Down Expand Up @@ -268,8 +282,11 @@ def test_send_request_raises_error_on_closed_stdout
stdout_write.close
end

error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
error = nil
assert_implicit_connect_deprecation_warning do
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
end

assert_equal("Server process closed stdout unexpectedly", error.message)
Expand Down Expand Up @@ -380,7 +397,9 @@ def test_send_request_skips_initialization_on_second_call
stdout_write.flush
end

transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" })
assert_implicit_connect_deprecation_warning do
transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" })
end
transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" })

assert_equal(
Expand Down Expand Up @@ -444,8 +463,11 @@ def test_send_request_raises_error_on_invalid_json
stdout_write.flush
end

error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
error = nil
assert_implicit_connect_deprecation_warning do
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
end

assert_equal("Failed to parse server response", error.message)
Expand Down Expand Up @@ -486,8 +508,11 @@ def test_send_request_raises_error_when_initialization_fails
stdout_write.flush
end

error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
error = nil
assert_implicit_connect_deprecation_warning do
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
end

assert_equal("Server initialization failed: Invalid Request", error.message)
Expand Down Expand Up @@ -563,8 +588,11 @@ def test_read_response_raises_error_on_timeout
stdin_read.gets
end

error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
error = nil
assert_implicit_connect_deprecation_warning do
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
end

assert_equal("Timed out waiting for server response", error.message)
Expand Down Expand Up @@ -616,7 +644,9 @@ def test_send_request_raises_error_when_stdin_is_closed
end

# Complete handshake with a successful request
transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" })
assert_implicit_connect_deprecation_warning do
transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" })
end
server_thread.join

# Now close stdin to simulate broken pipe
Expand Down Expand Up @@ -708,8 +738,11 @@ def test_send_request_raises_error_for_missing_result
stdout_write.flush
end

error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
error = nil
assert_implicit_connect_deprecation_warning do
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
end

assert_equal("Server initialization failed: missing result in response", error.message)
Expand Down Expand Up @@ -768,6 +801,59 @@ def test_connect_performs_initialize_handshake_explicitly
stdout_write.close
end

def test_send_request_does_not_warn_after_explicit_connect
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe

Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])

transport = Stdio.new(command: "ruby", args: ["server.rb"])

server_thread = Thread.new do
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
stdout_write.puts(JSON.generate(
jsonrpc: "2.0",
id: init_request["id"],
result: {
protocolVersion: "2025-11-25",
capabilities: {},
serverInfo: { name: "test-server", version: "1.0.0" },
},
))
stdout_write.flush

stdin_read.gets

ping_line = stdin_read.gets
ping_request = JSON.parse(ping_line)
stdout_write.puts(JSON.generate(
jsonrpc: "2.0",
id: ping_request["id"],
result: {},
))
stdout_write.flush
end

assert_silent do
transport.connect
end

response = nil
assert_silent do
response = transport.send_request(request: { jsonrpc: "2.0", id: "ping-id", method: "ping" })
end

assert_equal("ping-id", response["id"])
ensure
server_thread.join
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end

def test_connect_caches_server_info
transport, server_thread, pipes = stub_successful_connect

Expand Down Expand Up @@ -1141,6 +1227,14 @@ def test_server_info_is_cleared_after_close

private

def assert_implicit_connect_deprecation_warning(&block)
original_verbose = $VERBOSE
$VERBOSE = false
assert_output(nil, IMPLICIT_CONNECT_DEPRECATION_WARNING, &block)
ensure
$VERBOSE = original_verbose
end

def stub_successful_connect
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
Expand Down