ruby-mcp-client 0.6.0 → 0.6.2

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.
@@ -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
- attr_reader :command
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
- def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, name: nil, logger: nil)
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,34 @@ 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 = retries
45
+ @max_retries = retries
34
46
  @retry_backoff = retry_backoff
35
- @read_timeout = read_timeout
47
+ @read_timeout = read_timeout
48
+ @env = env || {}
36
49
  end
37
50
 
38
- # Connect to the MCP server by launching the command process via stdout/stdin
51
+ # Connect to the MCP server by launching the command process via stdin/stdout
39
52
  # @return [Boolean] true if connection was successful
40
53
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
41
54
  def connect
42
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
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
+ elsif @env.any?
62
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command)
63
+ else
64
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
65
+ end
43
66
  true
44
67
  rescue StandardError => e
45
68
  raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
46
69
  end
47
70
 
48
71
  # Spawn a reader thread to collect JSON-RPC responses
72
+ # @return [Thread] the reader thread
49
73
  def start_reader
50
74
  @reader_thread = Thread.new do
51
75
  @stdout.each_line do |line|
@@ -59,6 +83,7 @@ module MCPClient
59
83
  # Handle a line of output from the stdio server
60
84
  # Parses JSON-RPC messages and adds them to pending responses
61
85
  # @param line [String] line of output to parse
86
+ # @return [void]
62
87
  def handle_line(line)
63
88
  msg = JSON.parse(line)
64
89
  @logger.debug("Received line: #{line.chomp}")
@@ -128,6 +153,7 @@ module MCPClient
128
153
 
129
154
  # Clean up the server connection
130
155
  # Closes all stdio handles and terminates any running processes and threads
156
+ # @return [void]
131
157
  def cleanup
132
158
  return unless @stdin
133
159
 
@@ -144,125 +170,5 @@ module MCPClient
144
170
  ensure
145
171
  @stdin = @stdout = @stderr = @wait_thread = @reader_thread = nil
146
172
  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
173
  end
268
174
  end
@@ -2,5 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.6.0'
5
+ VERSION = '0.6.2'
6
+
7
+ # JSON-RPC handshake protocol version (date-based)
8
+ PROTOCOL_VERSION = '2024-11-05'
6
9
  end
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(command: cmd_list, name: cfg[:name], logger: logger)
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.0
4
+ version: 0.6.2
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-16 00:00:00.000000000 Z
11
+ date: 2025-05-20 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