ruby-mcp-client 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/mcp_client/json_rpc_common.rb +84 -0
- data/lib/mcp_client/server_factory.rb +52 -18
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +246 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +226 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +131 -0
- data/lib/mcp_client/server_sse.rb +53 -678
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +122 -0
- data/lib/mcp_client/server_stdio.rb +33 -125
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +10 -4
- metadata +7 -2
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp_client/json_rpc_common'
|
4
|
+
|
5
|
+
module MCPClient
|
6
|
+
class ServerStdio
|
7
|
+
# JSON-RPC request/notification plumbing for stdio transport
|
8
|
+
module JsonRpcTransport
|
9
|
+
include JsonRpcCommon
|
10
|
+
# Ensure the server process is started and initialized (handshake)
|
11
|
+
# @return [void]
|
12
|
+
# @raise [MCPClient::Errors::ConnectionError] if initialization fails
|
13
|
+
def ensure_initialized
|
14
|
+
return if @initialized
|
15
|
+
|
16
|
+
connect
|
17
|
+
start_reader
|
18
|
+
perform_initialize
|
19
|
+
|
20
|
+
@initialized = true
|
21
|
+
end
|
22
|
+
|
23
|
+
# Handshake: send initialize request and initialized notification
|
24
|
+
# @return [void]
|
25
|
+
# @raise [MCPClient::Errors::ConnectionError] if initialization fails
|
26
|
+
def perform_initialize
|
27
|
+
# Initialize request
|
28
|
+
init_id = next_id
|
29
|
+
init_req = build_jsonrpc_request('initialize', initialization_params, init_id)
|
30
|
+
send_request(init_req)
|
31
|
+
res = wait_response(init_id)
|
32
|
+
if (err = res['error'])
|
33
|
+
raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
|
34
|
+
end
|
35
|
+
|
36
|
+
# Send initialized notification
|
37
|
+
notif = build_jsonrpc_notification('notifications/initialized', {})
|
38
|
+
@stdin.puts(notif.to_json)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Generate a new unique request ID
|
42
|
+
# @return [Integer] a unique request ID
|
43
|
+
def next_id
|
44
|
+
@mutex.synchronize do
|
45
|
+
id = @next_id
|
46
|
+
@next_id += 1
|
47
|
+
id
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Send a JSON-RPC request and return nothing
|
52
|
+
# @param req [Hash] the JSON-RPC request
|
53
|
+
# @return [void]
|
54
|
+
# @raise [MCPClient::Errors::TransportError] on write errors
|
55
|
+
def send_request(req)
|
56
|
+
@logger.debug("Sending JSONRPC request: #{req.to_json}")
|
57
|
+
@stdin.puts(req.to_json)
|
58
|
+
rescue StandardError => e
|
59
|
+
raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
|
60
|
+
end
|
61
|
+
|
62
|
+
# Wait for a response with the given request ID
|
63
|
+
# @param id [Integer] the request ID
|
64
|
+
# @return [Hash] the JSON-RPC response message
|
65
|
+
# @raise [MCPClient::Errors::TransportError] on timeout
|
66
|
+
def wait_response(id)
|
67
|
+
deadline = Time.now + @read_timeout
|
68
|
+
@mutex.synchronize do
|
69
|
+
until @pending.key?(id)
|
70
|
+
remaining = deadline - Time.now
|
71
|
+
break if remaining <= 0
|
72
|
+
|
73
|
+
@cond.wait(@mutex, remaining)
|
74
|
+
end
|
75
|
+
msg = @pending[id]
|
76
|
+
@pending[id] = nil
|
77
|
+
raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
|
78
|
+
|
79
|
+
msg
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Stream tool call fallback for stdio transport (yields single result)
|
84
|
+
# @param tool_name [String] the name of the tool to call
|
85
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
86
|
+
# @return [Enumerator] a stream containing a single result
|
87
|
+
def call_tool_streaming(tool_name, parameters)
|
88
|
+
Enumerator.new do |yielder|
|
89
|
+
yielder << call_tool(tool_name, parameters)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Generic JSON-RPC request: send method with params and wait for result
|
94
|
+
# @param method [String] JSON-RPC method
|
95
|
+
# @param params [Hash] parameters for the request
|
96
|
+
# @return [Object] result from JSON-RPC response
|
97
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
98
|
+
# @raise [MCPClient::Errors::TransportError] on transport errors
|
99
|
+
# @raise [MCPClient::Errors::ToolCallError] on tool call errors
|
100
|
+
def rpc_request(method, params = {})
|
101
|
+
ensure_initialized
|
102
|
+
with_retry do
|
103
|
+
req_id = next_id
|
104
|
+
req = build_jsonrpc_request(method, params, req_id)
|
105
|
+
send_request(req)
|
106
|
+
res = wait_response(req_id)
|
107
|
+
process_jsonrpc_response(res)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Send a JSON-RPC notification (no response expected)
|
112
|
+
# @param method [String] JSON-RPC method
|
113
|
+
# @param params [Hash] parameters for the notification
|
114
|
+
# @return [void]
|
115
|
+
def rpc_notify(method, params = {})
|
116
|
+
ensure_initialized
|
117
|
+
notif = build_jsonrpc_notification(method, params)
|
118
|
+
@stdin.puts(notif.to_json)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -8,19 +8,31 @@ require 'logger'
|
|
8
8
|
module MCPClient
|
9
9
|
# JSON-RPC implementation of MCP server over stdio.
|
10
10
|
class ServerStdio < ServerBase
|
11
|
-
|
11
|
+
require 'mcp_client/server_stdio/json_rpc_transport'
|
12
|
+
|
13
|
+
include JsonRpcTransport
|
14
|
+
|
15
|
+
# @!attribute [r] command
|
16
|
+
# @return [String, Array] the command used to launch the server
|
17
|
+
# @!attribute [r] env
|
18
|
+
# @return [Hash] environment variables for the subprocess
|
19
|
+
attr_reader :command, :env
|
12
20
|
|
13
21
|
# Timeout in seconds for responses
|
14
22
|
READ_TIMEOUT = 15
|
15
23
|
|
24
|
+
# Initialize a new ServerStdio instance
|
16
25
|
# @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
|
26
|
+
# For improved security, passing an Array is recommended to avoid shell injection issues
|
17
27
|
# @param retries [Integer] number of retry attempts on transient errors
|
18
28
|
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
19
29
|
# @param read_timeout [Numeric] timeout in seconds for reading responses
|
20
30
|
# @param name [String, nil] optional name for this server
|
21
31
|
# @param logger [Logger, nil] optional logger
|
22
|
-
|
32
|
+
# @param env [Hash] optional environment variables for the subprocess
|
33
|
+
def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, name: nil, logger: nil, env: {})
|
23
34
|
super(name: name)
|
35
|
+
@command_array = command.is_a?(Array) ? command : nil
|
24
36
|
@command = command.is_a?(Array) ? command.join(' ') : command
|
25
37
|
@mutex = Mutex.new
|
26
38
|
@cond = ConditionVariable.new
|
@@ -30,22 +42,36 @@ module MCPClient
|
|
30
42
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
31
43
|
@logger.progname = self.class.name
|
32
44
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
33
|
-
@max_retries
|
45
|
+
@max_retries = retries
|
34
46
|
@retry_backoff = retry_backoff
|
35
|
-
@read_timeout
|
47
|
+
@read_timeout = read_timeout
|
48
|
+
@env = env || {}
|
36
49
|
end
|
37
50
|
|
38
51
|
# Connect to the MCP server by launching the command process via stdout/stdin
|
39
52
|
# @return [Boolean] true if connection was successful
|
40
53
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
41
54
|
def connect
|
42
|
-
|
55
|
+
if @command_array
|
56
|
+
if @env.any?
|
57
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, *@command_array)
|
58
|
+
else
|
59
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*@command_array)
|
60
|
+
end
|
61
|
+
else
|
62
|
+
if @env.any?
|
63
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command)
|
64
|
+
else
|
65
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
|
66
|
+
end
|
67
|
+
end
|
43
68
|
true
|
44
69
|
rescue StandardError => e
|
45
70
|
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
|
46
71
|
end
|
47
72
|
|
48
73
|
# Spawn a reader thread to collect JSON-RPC responses
|
74
|
+
# @return [Thread] the reader thread
|
49
75
|
def start_reader
|
50
76
|
@reader_thread = Thread.new do
|
51
77
|
@stdout.each_line do |line|
|
@@ -59,6 +85,7 @@ module MCPClient
|
|
59
85
|
# Handle a line of output from the stdio server
|
60
86
|
# Parses JSON-RPC messages and adds them to pending responses
|
61
87
|
# @param line [String] line of output to parse
|
88
|
+
# @return [void]
|
62
89
|
def handle_line(line)
|
63
90
|
msg = JSON.parse(line)
|
64
91
|
@logger.debug("Received line: #{line.chomp}")
|
@@ -128,6 +155,7 @@ module MCPClient
|
|
128
155
|
|
129
156
|
# Clean up the server connection
|
130
157
|
# Closes all stdio handles and terminates any running processes and threads
|
158
|
+
# @return [void]
|
131
159
|
def cleanup
|
132
160
|
return unless @stdin
|
133
161
|
|
@@ -144,125 +172,5 @@ module MCPClient
|
|
144
172
|
ensure
|
145
173
|
@stdin = @stdout = @stderr = @wait_thread = @reader_thread = nil
|
146
174
|
end
|
147
|
-
|
148
|
-
private
|
149
|
-
|
150
|
-
# Ensure the server process is started and initialized (handshake)
|
151
|
-
def ensure_initialized
|
152
|
-
return if @initialized
|
153
|
-
|
154
|
-
connect
|
155
|
-
start_reader
|
156
|
-
perform_initialize
|
157
|
-
|
158
|
-
@initialized = true
|
159
|
-
end
|
160
|
-
|
161
|
-
# Handshake: send initialize request and initialized notification
|
162
|
-
def perform_initialize
|
163
|
-
# Initialize request
|
164
|
-
init_id = next_id
|
165
|
-
init_req = {
|
166
|
-
'jsonrpc' => '2.0',
|
167
|
-
'id' => init_id,
|
168
|
-
'method' => 'initialize',
|
169
|
-
'params' => {
|
170
|
-
'protocolVersion' => '2024-11-05',
|
171
|
-
'capabilities' => {},
|
172
|
-
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
173
|
-
}
|
174
|
-
}
|
175
|
-
send_request(init_req)
|
176
|
-
res = wait_response(init_id)
|
177
|
-
if (err = res['error'])
|
178
|
-
raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
|
179
|
-
end
|
180
|
-
|
181
|
-
# Send initialized notification
|
182
|
-
notif = { 'jsonrpc' => '2.0', 'method' => 'notifications/initialized', 'params' => {} }
|
183
|
-
@stdin.puts(notif.to_json)
|
184
|
-
end
|
185
|
-
|
186
|
-
def next_id
|
187
|
-
@mutex.synchronize do
|
188
|
-
id = @next_id
|
189
|
-
@next_id += 1
|
190
|
-
id
|
191
|
-
end
|
192
|
-
end
|
193
|
-
|
194
|
-
def send_request(req)
|
195
|
-
@logger.debug("Sending JSONRPC request: #{req.to_json}")
|
196
|
-
@stdin.puts(req.to_json)
|
197
|
-
rescue StandardError => e
|
198
|
-
raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
|
199
|
-
end
|
200
|
-
|
201
|
-
def wait_response(id)
|
202
|
-
deadline = Time.now + @read_timeout
|
203
|
-
@mutex.synchronize do
|
204
|
-
until @pending.key?(id)
|
205
|
-
remaining = deadline - Time.now
|
206
|
-
break if remaining <= 0
|
207
|
-
|
208
|
-
@cond.wait(@mutex, remaining)
|
209
|
-
end
|
210
|
-
msg = @pending[id]
|
211
|
-
@pending[id] = nil
|
212
|
-
raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
|
213
|
-
|
214
|
-
msg
|
215
|
-
end
|
216
|
-
end
|
217
|
-
|
218
|
-
# Stream tool call fallback for stdio transport (yields single result)
|
219
|
-
# @param tool_name [String]
|
220
|
-
# @param parameters [Hash]
|
221
|
-
# @return [Enumerator]
|
222
|
-
def call_tool_streaming(tool_name, parameters)
|
223
|
-
Enumerator.new do |yielder|
|
224
|
-
yielder << call_tool(tool_name, parameters)
|
225
|
-
end
|
226
|
-
end
|
227
|
-
|
228
|
-
# Generic JSON-RPC request: send method with params and wait for result
|
229
|
-
# @param method [String] JSON-RPC method
|
230
|
-
# @param params [Hash] parameters for the request
|
231
|
-
# @return [Object] result from JSON-RPC response
|
232
|
-
def rpc_request(method, params = {})
|
233
|
-
ensure_initialized
|
234
|
-
attempts = 0
|
235
|
-
begin
|
236
|
-
req_id = next_id
|
237
|
-
req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => method, 'params' => params }
|
238
|
-
send_request(req)
|
239
|
-
res = wait_response(req_id)
|
240
|
-
if (err = res['error'])
|
241
|
-
raise MCPClient::Errors::ServerError, err['message']
|
242
|
-
end
|
243
|
-
|
244
|
-
res['result']
|
245
|
-
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
|
246
|
-
Errno::ECONNRESET => e
|
247
|
-
attempts += 1
|
248
|
-
if attempts <= @max_retries
|
249
|
-
delay = @retry_backoff * (2**(attempts - 1))
|
250
|
-
@logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
|
251
|
-
sleep(delay)
|
252
|
-
retry
|
253
|
-
end
|
254
|
-
raise
|
255
|
-
end
|
256
|
-
end
|
257
|
-
|
258
|
-
# Send a JSON-RPC notification (no response expected)
|
259
|
-
# @param method [String] JSON-RPC method
|
260
|
-
# @param params [Hash] parameters for the notification
|
261
|
-
# @return [void]
|
262
|
-
def rpc_notify(method, params = {})
|
263
|
-
ensure_initialized
|
264
|
-
notif = { 'jsonrpc' => '2.0', 'method' => method, 'params' => params }
|
265
|
-
@stdin.puts(notif.to_json)
|
266
|
-
end
|
267
175
|
end
|
268
176
|
end
|
data/lib/mcp_client/version.rb
CHANGED
data/lib/mcp_client.rb
CHANGED
@@ -33,9 +33,14 @@ module MCPClient
|
|
33
33
|
parsed.each_value do |cfg|
|
34
34
|
case cfg[:type].to_s
|
35
35
|
when 'stdio'
|
36
|
-
# Build command list with args
|
36
|
+
# Build command list with args and propagate environment
|
37
37
|
cmd_list = [cfg[:command]] + Array(cfg[:args])
|
38
|
-
configs << MCPClient.stdio_config(
|
38
|
+
configs << MCPClient.stdio_config(
|
39
|
+
command: cmd_list,
|
40
|
+
name: cfg[:name],
|
41
|
+
logger: logger,
|
42
|
+
env: cfg[:env]
|
43
|
+
)
|
39
44
|
when 'sse'
|
40
45
|
# Use 'url' from parsed config as 'base_url' for SSE config
|
41
46
|
configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {}, name: cfg[:name],
|
@@ -51,12 +56,13 @@ module MCPClient
|
|
51
56
|
# @param name [String, nil] optional name for this server
|
52
57
|
# @param logger [Logger, nil] optional logger for server operations
|
53
58
|
# @return [Hash] server configuration
|
54
|
-
def self.stdio_config(command:, name: nil, logger: nil)
|
59
|
+
def self.stdio_config(command:, name: nil, logger: nil, env: {})
|
55
60
|
{
|
56
61
|
type: 'stdio',
|
57
62
|
command: command,
|
58
63
|
name: name,
|
59
|
-
logger: logger
|
64
|
+
logger: logger,
|
65
|
+
env: env || {}
|
60
66
|
}
|
61
67
|
end
|
62
68
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby-mcp-client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.6.
|
4
|
+
version: 0.6.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Szymon Kurcab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-05-
|
11
|
+
date: 2025-05-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|
@@ -108,10 +108,15 @@ files:
|
|
108
108
|
- lib/mcp_client/client.rb
|
109
109
|
- lib/mcp_client/config_parser.rb
|
110
110
|
- lib/mcp_client/errors.rb
|
111
|
+
- lib/mcp_client/json_rpc_common.rb
|
111
112
|
- lib/mcp_client/server_base.rb
|
112
113
|
- lib/mcp_client/server_factory.rb
|
113
114
|
- lib/mcp_client/server_sse.rb
|
115
|
+
- lib/mcp_client/server_sse/json_rpc_transport.rb
|
116
|
+
- lib/mcp_client/server_sse/reconnect_monitor.rb
|
117
|
+
- lib/mcp_client/server_sse/sse_parser.rb
|
114
118
|
- lib/mcp_client/server_stdio.rb
|
119
|
+
- lib/mcp_client/server_stdio/json_rpc_transport.rb
|
115
120
|
- lib/mcp_client/tool.rb
|
116
121
|
- lib/mcp_client/version.rb
|
117
122
|
homepage: https://github.com/simonx1/ruby-mcp-client
|