Merge pull request #262 from koic/stdio_client_transport

Add `MCP::Client::Stdio` transport
This commit is contained in:
Koichi ITO 2026-03-20 07:56:02 +09:00 • committed by GitHub
commit 09b4e30da6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,726 @@
# frozen_string_literal: true
require "test_helper"
require "json"
require "mcp/client"
require "mcp/client/stdio"
require "mcp/client/tool"
module MCP
class Client
class StdioTest < Minitest::Test
def test_send_request_starts_process_and_returns_response
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
# Simulate server responses: initialize response, then tools/list response
Thread.new do
# Read and respond to initialize request
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
init_response = {
jsonrpc: "2.0",
id: init_request["id"],
result: {
protocolVersion: "2025-11-25",
capabilities: {},
serverInfo: { name: "test-server", version: "1.0.0" },
},
}
stdout_write.puts(JSON.generate(init_response))
stdout_write.flush
# Read initialized notification
stdin_read.gets
# Read and respond to tools/list request
tools_line = stdin_read.gets
tools_request = JSON.parse(tools_line)
tools_response = {
jsonrpc: "2.0",
id: tools_request["id"],
result: { tools: [{ name: "test_tool", description: "A test tool", inputSchema: {} }] },
}
stdout_write.puts(JSON.generate(tools_response))
stdout_write.flush
end
response = transport.send_request(request: request)
assert_equal("test-id", response["id"])
assert_equal(1, response.dig("result", "tools").size)
assert_equal("test_tool", response.dig("result", "tools", 0, "name"))
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_initializes_session_on_first_call
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
received_methods = []
Thread.new do
# Read initialize request
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
received_methods << init_request["method"]
init_response = {
jsonrpc: "2.0",
id: init_request["id"],
result: {
protocolVersion: "2025-11-25",
capabilities: {},
serverInfo: { name: "test-server", version: "1.0.0" },
},
}
stdout_write.puts(JSON.generate(init_response))
stdout_write.flush
# Read initialized notification
notification_line = stdin_read.gets
notification = JSON.parse(notification_line)
received_methods << notification["method"]
# Read tools/list request
tools_line = stdin_read.gets
tools_request = JSON.parse(tools_line)
received_methods << tools_request["method"]
tools_response = {
jsonrpc: "2.0",
id: tools_request["id"],
result: { tools: [] },
}
stdout_write.puts(JSON.generate(tools_response))
stdout_write.flush
end
transport.send_request(request: request)
assert_equal(["initialize", "notifications/initialized", "tools/list"], received_methods)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_skips_notifications
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
Thread.new do
# Handle initialize handshake
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
# Read initialized notification
stdin_read.gets
# Read tools/list request
stdin_read.gets
# Send a notification before the response
notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" }
stdout_write.puts(JSON.generate(notification))
stdout_write.flush
# Then send the actual response
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: "test-id",
result: { tools: [] },
}))
stdout_write.flush
end
response = transport.send_request(request: request)
assert_equal("test-id", response["id"])
assert_equal([], response.dig("result", "tools"))
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_raises_error_when_process_exits
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
dead_thread = mock("wait_thread")
dead_thread.stubs(:alive?).returns(false)
dead_thread.stubs(:value).returns(nil)
stderr_read, _ = IO.pipe
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, dead_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
transport.start
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
assert_equal("Server process has exited", error.message)
assert_equal(:internal_error, error.error_type)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_raises_error_on_closed_stdout
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
Thread.new do
# Handle initialize handshake
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
# Read initialized notification
stdin_read.gets
# Read tools/list request, then close stdout
stdin_read.gets
stdout_write.close
end
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
assert_equal("Server process closed stdout unexpectedly", error.message)
assert_equal(:internal_error, error.error_type)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_close_resets_state
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, stderr_write = IO.pipe
wait_thread = mock("wait_thread")
wait_thread.stubs(:alive?).returns(true)
wait_thread.stubs(:value).returns(nil)
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
transport.start
assert(transport.instance_variable_get(:@started))
transport.close
refute(transport.instance_variable_get(:@started))
refute(transport.instance_variable_get(:@initialized))
ensure
stdin_read.close
begin
stdin_write.close
rescue
nil
end
begin
stdout_read.close
rescue
nil
end
stdout_write.close
begin
stderr_read.close
rescue
nil
end
stderr_write.close
end
def test_send_request_skips_initialization_on_second_call
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"])
received_methods = []
Thread.new do
# First call: initialize handshake
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
received_methods << init_request["method"]
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
# Read initialized notification
notification_line = stdin_read.gets
received_methods << JSON.parse(notification_line)["method"]
# First request: tools/list
first_line = stdin_read.gets
first_request = JSON.parse(first_line)
received_methods << first_request["method"]
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: first_request["id"],
result: { tools: [] },
}))
stdout_write.flush
# Second request: tools/list (no re-initialization)
second_line = stdin_read.gets
second_request = JSON.parse(second_line)
received_methods << second_request["method"]
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: second_request["id"],
result: { tools: [] },
}))
stdout_write.flush
end
transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" })
transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" })
assert_equal(
["initialize", "notifications/initialized", "tools/list", "tools/list"],
received_methods,
)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_env_is_passed_to_process
transport = Stdio.new(command: "ruby", args: ["server.rb"], env: { "FOO" => "bar" })
Open3.expects(:popen3).with({ "FOO" => "bar" }, "ruby", "server.rb").returns(
[StringIO.new, StringIO.new, StringIO.new, mock_wait_thread],
)
transport.start
end
def test_send_request_raises_error_on_invalid_json
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
Thread.new do
# Handle initialize handshake
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
# Read initialized notification
stdin_read.gets
# Read tools/list request, then send invalid JSON
stdin_read.gets
stdout_write.puts("not valid json")
stdout_write.flush
end
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
assert_equal("Failed to parse server response", error.message)
assert_equal(:internal_error, error.error_type)
assert_instance_of(JSON::ParserError, error.original_error)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_raises_error_when_initialization_fails
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
Thread.new do
# Read initialize request and return an error
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: init_request["id"],
error: { code: -32600, message: "Invalid Request", data: "Unsupported protocol version" },
}))
stdout_write.flush
end
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
assert_equal("Server initialization failed: Invalid Request", error.message)
assert_equal(:internal_error, error.error_type)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_close_kills_process_on_timeout
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe
hanging_thread = mock("wait_thread")
hanging_thread.stubs(:alive?).returns(true)
hanging_thread.stubs(:pid).returns(99999)
hanging_thread.stubs(:value).raises(Timeout::Error)
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, hanging_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
transport.start
Process.expects(:kill).with("TERM", 99999).once
Process.expects(:kill).with("KILL", 99999).once
transport.close
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_read_response_raises_error_on_timeout
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"], read_timeout: 0.01)
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
Thread.new do
# Handle initialize handshake
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
# Read initialized notification
stdin_read.gets
# Read tools/list request but don't respond (simulate timeout)
stdin_read.gets
end
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
assert_equal("Timed out waiting for server response", error.message)
assert_equal(:internal_error, error.error_type)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_raises_error_when_stdin_is_closed
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
# Handle initialize handshake
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
# Read initialized notification
stdin_read.gets
# Read and respond to first request
line = stdin_read.gets
request = JSON.parse(line)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: request["id"],
result: {},
}))
stdout_write.flush
end
# Complete handshake with a successful request
transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" })
server_thread.join
# Now close stdin to simulate broken pipe
stdin_write.close
error = assert_raises(RequestHandlerError) do
transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" })
end
assert_equal("Failed to write to server process", error.message)
assert_equal(:internal_error, error.error_type)
ensure
stdin_read.close
begin
stdin_write.close
rescue
nil
end
stdout_read.close
stdout_write.close
end
def test_close_is_noop_when_not_started
transport = Stdio.new(command: "ruby", args: ["server.rb"])
# Should not raise
transport.close
end
def test_start_raises_error_when_already_started
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"])
transport.start
error = assert_raises(RuntimeError) do
transport.start
end
assert_equal("MCP::Client::Stdio already started", error.message)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_start_raises_error_for_invalid_command
Open3.stubs(:popen3).raises(Errno::ENOENT.new("No such file or directory - nonexistent_command"))
transport = Stdio.new(command: "nonexistent_command")
error = assert_raises(RequestHandlerError) do
transport.start
end
assert_match(/Failed to spawn server process/, error.message)
assert_equal(:internal_error, error.error_type)
assert_instance_of(Errno::ENOENT, error.original_error)
end
def test_send_request_raises_error_for_missing_result
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"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
Thread.new do
# Read initialize request and return a response without result
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: init_request["id"],
}))
stdout_write.flush
end
error = assert_raises(RequestHandlerError) do
transport.send_request(request: request)
end
assert_equal("Server initialization failed: missing result in response", error.message)
assert_equal(:internal_error, error.error_type)
ensure
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
private
def mock_wait_thread
thread = mock("wait_thread")
thread.stubs(:alive?).returns(true)
thread.stubs(:value).returns(nil)
thread
end
end
end
end