Skip to content

Commit 8f1842e

Browse files
committed
add stdio transport
1 parent 75f2ae8 commit 8f1842e

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed

examples/stdio_client.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5+
require "mcp"
6+
require "mcp/client/transports/stdio"
7+
8+
# Create a client with stdio transport
9+
transport = MCP::Client::Transports::Stdio.new(timeout: 10)
10+
client = MCP::Client.new(transport: transport)
11+
12+
begin
13+
# Initialize the session
14+
result = client.initialize_session(
15+
protocol_version: "2025-03-26",
16+
capabilities: {},
17+
client_info: {
18+
name: "example_stdio_client",
19+
version: "1.0.0",
20+
},
21+
)
22+
23+
puts "Connected to server: #{result[:serverInfo][:name]} v#{result[:serverInfo][:version]}"
24+
puts "Server capabilities: #{result[:capabilities].keys.join(", ")}"
25+
26+
# Test ping
27+
ping_result = client.ping
28+
puts "Ping successful: #{ping_result}"
29+
30+
# List available tools
31+
tools_result = client.list_tools
32+
puts "Available tools:"
33+
tools_result[:tools].each do |tool|
34+
puts " - #{tool[:name]}: #{tool[:description]}"
35+
end
36+
37+
# Call a tool if available
38+
if tools_result[:tools].any?
39+
tool_name = tools_result[:tools].first[:name]
40+
puts "\nCalling tool: #{tool_name}"
41+
42+
# Example arguments - adjust based on the actual tool
43+
tool_result = client.call_tool(
44+
name: tool_name,
45+
arguments: tool_name == "echo" ? { message: "Hello from stdio client!" } : {},
46+
)
47+
48+
puts "Tool result:"
49+
tool_result[:content].each do |content|
50+
puts " #{content[:text]}" if content[:type] == "text"
51+
end
52+
end
53+
rescue MCP::Client::ClientError => e
54+
puts "Client error: #{e.message}"
55+
puts "Error type: #{e.error_type}"
56+
rescue MCP::Client::Transports::Stdio::StdioError => e
57+
puts "Stdio transport error: #{e.message}"
58+
puts "Original error: #{e.original_error}" if e.original_error
59+
rescue StandardError => e
60+
puts "Unexpected error: #{e.message}"
61+
puts e.backtrace.join("\n")
62+
end

lib/mcp/client/transports/stdio.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "stringio"
5+
6+
module MCP
7+
class Client
8+
module Transports
9+
class Stdio
10+
class StdioError < StandardError
11+
attr_reader :original_error
12+
13+
def initialize(message, original_error: nil)
14+
super(message)
15+
@original_error = original_error
16+
end
17+
end
18+
19+
attr_reader :timeout
20+
21+
def initialize(timeout: 30)
22+
@timeout = timeout
23+
@stdin = $stdin
24+
@stdout = $stdout
25+
@stdin.set_encoding("UTF-8")
26+
@stdout.set_encoding("UTF-8")
27+
end
28+
29+
def send_request(request)
30+
json_request = JSON.generate(request)
31+
32+
begin
33+
# Send request to stdout
34+
@stdout.puts(json_request)
35+
@stdout.flush
36+
37+
# Read response from stdin with timeout
38+
response_line = read_with_timeout(@timeout)
39+
40+
unless response_line
41+
raise StdioError.new("No response received within #{@timeout} seconds")
42+
end
43+
44+
# Parse the JSON response
45+
JSON.parse(response_line.strip, symbolize_names: true)
46+
rescue JSON::ParserError => e
47+
raise StdioError.new("Invalid JSON response: #{e.message}", original_error: e)
48+
rescue Errno::EPIPE => e
49+
raise StdioError.new("Broken pipe: #{e.message}", original_error: e)
50+
rescue IOError => e
51+
raise StdioError.new("IO error: #{e.message}", original_error: e)
52+
rescue StandardError => e
53+
raise StdioError.new("Stdio transport error: #{e.message}", original_error: e)
54+
end
55+
end
56+
57+
private
58+
59+
def read_with_timeout(timeout)
60+
# Handle StringIO for testing
61+
if @stdin.is_a?(StringIO)
62+
@stdin.gets
63+
elsif IO.select([@stdin], nil, nil, timeout)
64+
@stdin.gets
65+
else
66+
nil
67+
end
68+
end
69+
end
70+
end
71+
end
72+
end
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "mcp/client/transports/stdio"
5+
require "json"
6+
7+
module MCP
8+
class Client
9+
module Transports
10+
class StdioTest < ActiveSupport::TestCase
11+
setup do
12+
@transport = Stdio.new(timeout: 1)
13+
end
14+
15+
test "initializes with default timeout" do
16+
transport = Stdio.new
17+
assert_equal 30, transport.timeout
18+
end
19+
20+
test "initializes with custom timeout" do
21+
transport = Stdio.new(timeout: 10)
22+
assert_equal 10, transport.timeout
23+
end
24+
25+
test "sends request and receives response successfully" do
26+
request = {
27+
jsonrpc: "2.0",
28+
method: "ping",
29+
id: 1,
30+
}
31+
32+
response = {
33+
jsonrpc: "2.0",
34+
id: 1,
35+
result: {},
36+
}
37+
38+
# Mock stdin and stdout
39+
mock_stdin = StringIO.new(JSON.generate(response) + "\n")
40+
mock_stdout = StringIO.new
41+
42+
@transport.instance_variable_set(:@stdin, mock_stdin)
43+
@transport.instance_variable_set(:@stdout, mock_stdout)
44+
45+
result = @transport.send_request(request)
46+
47+
# Verify request was sent to stdout
48+
assert_equal JSON.generate(request) + "\n", mock_stdout.string
49+
50+
# Verify response was parsed correctly
51+
assert_equal response, result
52+
end
53+
54+
test "raises error on timeout" do
55+
request = { jsonrpc: "2.0", method: "ping", id: 1 }
56+
57+
# Mock stdin that never returns data
58+
mock_stdin = StringIO.new("")
59+
mock_stdout = StringIO.new
60+
61+
@transport.instance_variable_set(:@stdin, mock_stdin)
62+
@transport.instance_variable_set(:@stdout, mock_stdout)
63+
64+
error = assert_raises(Stdio::StdioError) do
65+
@transport.send_request(request)
66+
end
67+
assert_match(/No response received within 1 seconds/, error.message)
68+
end
69+
70+
test "raises error on invalid JSON response" do
71+
request = { jsonrpc: "2.0", method: "ping", id: 1 }
72+
73+
# Mock stdin with invalid JSON
74+
mock_stdin = StringIO.new("invalid json\n")
75+
mock_stdout = StringIO.new
76+
77+
@transport.instance_variable_set(:@stdin, mock_stdin)
78+
@transport.instance_variable_set(:@stdout, mock_stdout)
79+
80+
error = assert_raises(Stdio::StdioError) do
81+
@transport.send_request(request)
82+
end
83+
assert_match(/Invalid JSON response/, error.message)
84+
assert_instance_of JSON::ParserError, error.original_error
85+
end
86+
87+
test "raises error on broken pipe" do
88+
request = { jsonrpc: "2.0", method: "ping", id: 1 }
89+
90+
mock_stdout = StringIO.new
91+
@transport.instance_variable_set(:@stdout, mock_stdout)
92+
93+
# Mock puts to raise EPIPE
94+
mock_stdout.define_singleton_method(:puts) do |*args|
95+
raise Errno::EPIPE.new("Broken pipe")
96+
end
97+
98+
error = assert_raises(Stdio::StdioError) do
99+
@transport.send_request(request)
100+
end
101+
assert_match(/Broken pipe/, error.message)
102+
assert_instance_of Errno::EPIPE, error.original_error
103+
end
104+
105+
test "raises error on IO error" do
106+
request = { jsonrpc: "2.0", method: "ping", id: 1 }
107+
108+
mock_stdout = StringIO.new
109+
@transport.instance_variable_set(:@stdout, mock_stdout)
110+
111+
# Mock puts to raise IOError
112+
mock_stdout.define_singleton_method(:puts) do |*args|
113+
raise IOError.new("IO error")
114+
end
115+
116+
error = assert_raises(Stdio::StdioError) do
117+
@transport.send_request(request)
118+
end
119+
assert_match(/IO error/, error.message)
120+
assert_instance_of IOError, error.original_error
121+
end
122+
end
123+
end
124+
end
125+
end

0 commit comments

Comments
 (0)