ruby-mcp-client 0.5.3 → 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.
@@ -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,18 +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
30
+ # @param name [String, nil] optional name for this server
20
31
  # @param logger [Logger, nil] optional logger
21
- def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, logger: nil)
22
- super()
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: {})
34
+ super(name: name)
35
+ @command_array = command.is_a?(Array) ? command : nil
23
36
  @command = command.is_a?(Array) ? command.join(' ') : command
24
37
  @mutex = Mutex.new
25
38
  @cond = ConditionVariable.new
@@ -29,22 +42,36 @@ module MCPClient
29
42
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
30
43
  @logger.progname = self.class.name
31
44
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
32
- @max_retries = retries
45
+ @max_retries = retries
33
46
  @retry_backoff = retry_backoff
34
- @read_timeout = read_timeout
47
+ @read_timeout = read_timeout
48
+ @env = env || {}
35
49
  end
36
50
 
37
51
  # Connect to the MCP server by launching the command process via stdout/stdin
38
52
  # @return [Boolean] true if connection was successful
39
53
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
40
54
  def connect
41
- @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
+ 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
42
68
  true
43
69
  rescue StandardError => e
44
70
  raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
45
71
  end
46
72
 
47
73
  # Spawn a reader thread to collect JSON-RPC responses
74
+ # @return [Thread] the reader thread
48
75
  def start_reader
49
76
  @reader_thread = Thread.new do
50
77
  @stdout.each_line do |line|
@@ -58,6 +85,7 @@ module MCPClient
58
85
  # Handle a line of output from the stdio server
59
86
  # Parses JSON-RPC messages and adds them to pending responses
60
87
  # @param line [String] line of output to parse
88
+ # @return [void]
61
89
  def handle_line(line)
62
90
  msg = JSON.parse(line)
63
91
  @logger.debug("Received line: #{line.chomp}")
@@ -93,7 +121,7 @@ module MCPClient
93
121
  raise MCPClient::Errors::ServerError, err['message']
94
122
  end
95
123
 
96
- (res.dig('result', 'tools') || []).map { |td| MCPClient::Tool.from_json(td) }
124
+ (res.dig('result', 'tools') || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
97
125
  rescue StandardError => e
98
126
  raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
99
127
  end
@@ -127,6 +155,7 @@ module MCPClient
127
155
 
128
156
  # Clean up the server connection
129
157
  # Closes all stdio handles and terminates any running processes and threads
158
+ # @return [void]
130
159
  def cleanup
131
160
  return unless @stdin
132
161
 
@@ -143,125 +172,5 @@ module MCPClient
143
172
  ensure
144
173
  @stdin = @stdout = @stderr = @wait_thread = @reader_thread = nil
145
174
  end
146
-
147
- private
148
-
149
- # Ensure the server process is started and initialized (handshake)
150
- def ensure_initialized
151
- return if @initialized
152
-
153
- connect
154
- start_reader
155
- perform_initialize
156
-
157
- @initialized = true
158
- end
159
-
160
- # Handshake: send initialize request and initialized notification
161
- def perform_initialize
162
- # Initialize request
163
- init_id = next_id
164
- init_req = {
165
- 'jsonrpc' => '2.0',
166
- 'id' => init_id,
167
- 'method' => 'initialize',
168
- 'params' => {
169
- 'protocolVersion' => '2024-11-05',
170
- 'capabilities' => {},
171
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
172
- }
173
- }
174
- send_request(init_req)
175
- res = wait_response(init_id)
176
- if (err = res['error'])
177
- raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
178
- end
179
-
180
- # Send initialized notification
181
- notif = { 'jsonrpc' => '2.0', 'method' => 'notifications/initialized', 'params' => {} }
182
- @stdin.puts(notif.to_json)
183
- end
184
-
185
- def next_id
186
- @mutex.synchronize do
187
- id = @next_id
188
- @next_id += 1
189
- id
190
- end
191
- end
192
-
193
- def send_request(req)
194
- @logger.debug("Sending JSONRPC request: #{req.to_json}")
195
- @stdin.puts(req.to_json)
196
- rescue StandardError => e
197
- raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
198
- end
199
-
200
- def wait_response(id)
201
- deadline = Time.now + @read_timeout
202
- @mutex.synchronize do
203
- until @pending.key?(id)
204
- remaining = deadline - Time.now
205
- break if remaining <= 0
206
-
207
- @cond.wait(@mutex, remaining)
208
- end
209
- msg = @pending[id]
210
- @pending[id] = nil
211
- raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
212
-
213
- msg
214
- end
215
- end
216
-
217
- # Stream tool call fallback for stdio transport (yields single result)
218
- # @param tool_name [String]
219
- # @param parameters [Hash]
220
- # @return [Enumerator]
221
- def call_tool_streaming(tool_name, parameters)
222
- Enumerator.new do |yielder|
223
- yielder << call_tool(tool_name, parameters)
224
- end
225
- end
226
-
227
- # Generic JSON-RPC request: send method with params and wait for result
228
- # @param method [String] JSON-RPC method
229
- # @param params [Hash] parameters for the request
230
- # @return [Object] result from JSON-RPC response
231
- def rpc_request(method, params = {})
232
- ensure_initialized
233
- attempts = 0
234
- begin
235
- req_id = next_id
236
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => method, 'params' => params }
237
- send_request(req)
238
- res = wait_response(req_id)
239
- if (err = res['error'])
240
- raise MCPClient::Errors::ServerError, err['message']
241
- end
242
-
243
- res['result']
244
- rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
245
- Errno::ECONNRESET => e
246
- attempts += 1
247
- if attempts <= @max_retries
248
- delay = @retry_backoff * (2**(attempts - 1))
249
- @logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
250
- sleep(delay)
251
- retry
252
- end
253
- raise
254
- end
255
- end
256
-
257
- # Send a JSON-RPC notification (no response expected)
258
- # @param method [String] JSON-RPC method
259
- # @param params [Hash] parameters for the notification
260
- # @return [void]
261
- def rpc_notify(method, params = {})
262
- ensure_initialized
263
- notif = { 'jsonrpc' => '2.0', 'method' => method, 'params' => params }
264
- @stdin.puts(notif.to_json)
265
- end
266
175
  end
267
176
  end
@@ -3,24 +3,40 @@
3
3
  module MCPClient
4
4
  # Representation of an MCP tool
5
5
  class Tool
6
- attr_reader :name, :description, :schema
6
+ # @!attribute [r] name
7
+ # @return [String] the name of the tool
8
+ # @!attribute [r] description
9
+ # @return [String] the description of the tool
10
+ # @!attribute [r] schema
11
+ # @return [Hash] the JSON schema for the tool
12
+ # @!attribute [r] server
13
+ # @return [MCPClient::ServerBase, nil] the server this tool belongs to
14
+ attr_reader :name, :description, :schema, :server
7
15
 
8
- def initialize(name:, description:, schema:)
16
+ # Initialize a new Tool
17
+ # @param name [String] the name of the tool
18
+ # @param description [String] the description of the tool
19
+ # @param schema [Hash] the JSON schema for the tool
20
+ # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
21
+ def initialize(name:, description:, schema:, server: nil)
9
22
  @name = name
10
23
  @description = description
11
24
  @schema = schema
25
+ @server = server
12
26
  end
13
27
 
14
28
  # Create a Tool instance from JSON data
15
29
  # @param data [Hash] JSON data from MCP server
30
+ # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
16
31
  # @return [MCPClient::Tool] tool instance
17
- def self.from_json(data)
32
+ def self.from_json(data, server: nil)
18
33
  # Some servers (Playwright MCP CLI) use 'inputSchema' instead of 'schema'
19
34
  schema = data['inputSchema'] || data['schema']
20
35
  new(
21
36
  name: data['name'],
22
37
  description: data['description'],
23
- schema: schema
38
+ schema: schema,
39
+ server: server
24
40
  )
25
41
  end
26
42
 
@@ -47,6 +63,8 @@ module MCPClient
47
63
  }
48
64
  end
49
65
 
66
+ # Convert tool to Google Vertex AI tool specification format
67
+ # @return [Hash] Google Vertex AI tool specification with cleaned schema
50
68
  def to_google_tool
51
69
  {
52
70
  name: @name,
@@ -2,5 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.5.3'
5
+ VERSION = '0.6.1'
6
+
7
+ # JSON-RPC handshake protocol version (date-based)
8
+ PROTOCOL_VERSION = '2024-11-05'
6
9
  end
data/lib/mcp_client.rb CHANGED
@@ -19,38 +19,50 @@ module MCPClient
19
19
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
20
20
  # @param server_definition_file [String, nil] optional path to a JSON file defining server configurations
21
21
  # The JSON may be a single server object or an array of server objects.
22
+ # @param logger [Logger, nil] optional logger for client operations
22
23
  # @return [MCPClient::Client] new client instance
23
- def self.create_client(mcp_server_configs: [], server_definition_file: nil)
24
+ def self.create_client(mcp_server_configs: [], server_definition_file: nil, logger: nil)
24
25
  require 'json'
25
26
  # Start with any explicit configs provided
26
27
  configs = Array(mcp_server_configs)
27
28
  # Load additional configs from a JSON file if specified
28
29
  if server_definition_file
29
30
  # Parse JSON definitions into clean config hashes
30
- parser = MCPClient::ConfigParser.new(server_definition_file)
31
+ parser = MCPClient::ConfigParser.new(server_definition_file, logger: logger)
31
32
  parsed = parser.parse
32
33
  parsed.each_value do |cfg|
33
34
  case cfg[:type].to_s
34
35
  when 'stdio'
35
- # Build command list with args
36
+ # Build command list with args and propagate environment
36
37
  cmd_list = [cfg[:command]] + Array(cfg[:args])
37
- configs << MCPClient.stdio_config(command: cmd_list)
38
+ configs << MCPClient.stdio_config(
39
+ command: cmd_list,
40
+ name: cfg[:name],
41
+ logger: logger,
42
+ env: cfg[:env]
43
+ )
38
44
  when 'sse'
39
45
  # Use 'url' from parsed config as 'base_url' for SSE config
40
- configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {})
46
+ configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {}, name: cfg[:name],
47
+ logger: logger)
41
48
  end
42
49
  end
43
50
  end
44
- MCPClient::Client.new(mcp_server_configs: configs)
51
+ MCPClient::Client.new(mcp_server_configs: configs, logger: logger)
45
52
  end
46
53
 
47
54
  # Create a standard server configuration for stdio
48
55
  # @param command [String, Array<String>] command to execute
56
+ # @param name [String, nil] optional name for this server
57
+ # @param logger [Logger, nil] optional logger for server operations
49
58
  # @return [Hash] server configuration
50
- def self.stdio_config(command:)
59
+ def self.stdio_config(command:, name: nil, logger: nil, env: {})
51
60
  {
52
61
  type: 'stdio',
53
- command: command
62
+ command: command,
63
+ name: name,
64
+ logger: logger,
65
+ env: env || {}
54
66
  }
55
67
  end
56
68
 
@@ -61,8 +73,11 @@ module MCPClient
61
73
  # @param ping [Integer] time in seconds after which to send ping if no activity (default: 10)
62
74
  # @param retries [Integer] number of retry attempts (default: 0)
63
75
  # @param retry_backoff [Integer] backoff delay in seconds (default: 1)
76
+ # @param name [String, nil] optional name for this server
77
+ # @param logger [Logger, nil] optional logger for server operations
64
78
  # @return [Hash] server configuration
65
- def self.sse_config(base_url:, headers: {}, read_timeout: 30, ping: 10, retries: 0, retry_backoff: 1)
79
+ def self.sse_config(base_url:, headers: {}, read_timeout: 30, ping: 10, retries: 0, retry_backoff: 1,
80
+ name: nil, logger: nil)
66
81
  {
67
82
  type: 'sse',
68
83
  base_url: base_url,
@@ -70,7 +85,9 @@ module MCPClient
70
85
  read_timeout: read_timeout,
71
86
  ping: ping,
72
87
  retries: retries,
73
- retry_backoff: retry_backoff
88
+ retry_backoff: retry_backoff,
89
+ name: name,
90
+ logger: logger
74
91
  }
75
92
  end
76
93
  end
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.5.3
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-13 00:00:00.000000000 Z
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