ruby-mcp-client 0.5.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79a86302428257274c4e620fae0b7ad45c6229cf381a956ef3b0a7e736f17f62
4
- data.tar.gz: 80727b0aa48a992054dafd7c484eb611404b47d878ef2869e59e937a815718dc
3
+ metadata.gz: fbd3caf1e80f6c2caf625495072211b827ae08e3542fa33306537918d0b6eb5f
4
+ data.tar.gz: 1eea548ba10e7de3fec68a92d449e4552fde3c5920af045e2e828925dba2aad7
5
5
  SHA512:
6
- metadata.gz: 977acc1ae8ca48a17b7827f006ed82578eb7bc24bf9ba08b03e77493e3447febc82287242a410861fb675b1fb74b0b67525dc4f92c9f8b448cd1cad2e3afc875
7
- data.tar.gz: f940ed03a52065a5d8687d1722a588693d69a9c69f820771b328fed3b1bb69068fc7110dbedc745ea4fc42d047afa8afe975b2ed821aa5d33ac8c4e25fac9da7
6
+ metadata.gz: e8eae783bd1e6a4db013a934e0686641befaec435fb288531ce85899efc0a66023d3d174dedffae21f75089f406f6368f286f13644701fbdee747492b795535b
7
+ data.tar.gz: 534cc5c88f8f3737c5fc3e684174980af74ac18398532e4b5698490b451b5fc5ff0831ca48639119445c71f4afa94ba3fca94ff81edfa03d1e3ba34a73c4f1b1
data/README.md CHANGED
@@ -56,10 +56,22 @@ client = MCPClient.create_client(
56
56
  retries: 3, # Optional number of retry attempts (default: 0)
57
57
  retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
58
58
  # Native support for tool streaming via call_tool_streaming method
59
- )
60
- ]
59
+ ) ]
61
60
  )
62
61
 
62
+ # Or load server definitions from a JSON file
63
+ client = MCPClient.create_client(
64
+ server_definition_file: 'path/to/server_definition.json'
65
+ )
66
+
67
+ # MCP server configuration JSON format can be:
68
+ # 1. A single server object:
69
+ # { "type": "sse", "url": "http://example.com/sse" }
70
+ # 2. An array of server objects:
71
+ # [{ "type": "stdio", "command": "npx server" }, { "type": "sse", "url": "http://..." }]
72
+ # 3. An object with "mcpServers" key containing named servers:
73
+ # { "mcpServers": { "server1": { "type": "sse", "url": "http://..." } } }
74
+
63
75
  # List available tools
64
76
  tools = client.list_tools
65
77
 
@@ -260,6 +272,62 @@ This client works with any MCP-compatible server, including:
260
272
  - [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
261
273
  - Custom servers implementing the MCP protocol
262
274
 
275
+ ### Server Definition Files
276
+
277
+ You can define MCP server configurations in JSON files for easier management:
278
+
279
+ ```json
280
+ {
281
+ "mcpServers": {
282
+ "playwright": {
283
+ "type": "sse",
284
+ "url": "http://localhost:8931/sse",
285
+ "headers": {
286
+ "Authorization": "Bearer TOKEN"
287
+ }
288
+ },
289
+ "filesystem": {
290
+ "type": "stdio",
291
+ "command": "npx",
292
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
293
+ "env": {
294
+ "DEBUG": "true"
295
+ }
296
+ }
297
+ }
298
+ }
299
+ ```
300
+
301
+ A simpler example used in the Playwright demo (found in `examples/sample_server_definition.json`):
302
+
303
+ ```json
304
+ {
305
+ "mcpServers": {
306
+ "playwright": {
307
+ "url": "http://localhost:8931/sse",
308
+ "headers": {},
309
+ "comment": "Local Playwright MCP Server running on port 8931"
310
+ }
311
+ }
312
+ }
313
+ ```
314
+
315
+ Load this configuration with:
316
+
317
+ ```ruby
318
+ client = MCPClient.create_client(server_definition_file: 'path/to/definition.json')
319
+ ```
320
+
321
+ The JSON format supports:
322
+ 1. A single server object: `{ "type": "sse", "url": "..." }`
323
+ 2. An array of server objects: `[{ "type": "stdio", ... }, { "type": "sse", ... }]`
324
+ 3. An object with named servers under `mcpServers` key (as shown above)
325
+
326
+ Special configuration options:
327
+ - `comment` and `description` are reserved keys that are ignored during parsing and can be used for documentation
328
+ - Server type can be inferred from the presence of either `command` (for stdio) or `url` (for SSE)
329
+ - All string values in arrays (like `args`) are automatically converted to strings
330
+
263
331
  ## Key Features
264
332
 
265
333
  ### Client Features
@@ -274,6 +342,7 @@ This client works with any MCP-compatible server, including:
274
342
  - **Server notifications** - Support for JSON-RPC notifications
275
343
  - **Custom RPC methods** - Send any custom JSON-RPC method
276
344
  - **Consistent error handling** - Rich error types for better exception handling
345
+ - **JSON configuration** - Support for server definition files in JSON format
277
346
 
278
347
  ### Server-Sent Events (SSE) Implementation
279
348
 
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'logger'
5
+
6
+ module MCPClient
7
+ # Parses MCP server definition JSON files into configuration hashes
8
+ class ConfigParser
9
+ # Reserved JSON keys that shouldn't be included in final config
10
+ RESERVED_KEYS = %w[comment description].freeze
11
+
12
+ # @param file_path [String] path to JSON file containing 'mcpServers' definitions
13
+ # @param logger [Logger, nil] optional logger for warnings
14
+ def initialize(file_path, logger: nil)
15
+ @file_path = file_path
16
+ @logger = logger || Logger.new($stdout, level: Logger::WARN)
17
+ end
18
+
19
+ # Parse the JSON config and return a mapping of server names to clean config hashes
20
+ # @return [Hash<String, Hash>] server name => config hash with symbol keys
21
+ def parse
22
+ content = File.read(@file_path)
23
+ data = JSON.parse(content)
24
+
25
+ servers_data = extract_servers_data(data)
26
+ servers_data = filter_reserved_keys(servers_data)
27
+
28
+ result = {}
29
+ servers_data.each do |server_name, config|
30
+ next unless validate_server_config(config, server_name)
31
+
32
+ server_config = process_server_config(config, server_name)
33
+ result[server_name] = server_config if server_config
34
+ end
35
+
36
+ result
37
+ rescue Errno::ENOENT
38
+ raise Errno::ENOENT, "Server definition file not found: #{@file_path}"
39
+ rescue JSON::ParserError => e
40
+ raise JSON::ParserError, "Invalid JSON in #{@file_path}: #{e.message}"
41
+ end
42
+
43
+ # Extract server data from parsed JSON
44
+ # @param data [Object] parsed JSON data
45
+ # @return [Hash] normalized server data
46
+ def extract_servers_data(data)
47
+ if data.is_a?(Hash) && data.key?('mcpServers') && data['mcpServers'].is_a?(Hash)
48
+ data['mcpServers']
49
+ elsif data.is_a?(Array)
50
+ h = {}
51
+ data.each_with_index { |cfg, idx| h[idx.to_s] = cfg }
52
+ h
53
+ elsif data.is_a?(Hash)
54
+ { '0' => data }
55
+ else
56
+ @logger.warn("Invalid root JSON structure in #{@file_path}: #{data.class}")
57
+ {}
58
+ end
59
+ end
60
+
61
+ # Validate server configuration is a hash
62
+ # @param config [Object] server configuration to validate
63
+ # @param server_name [String] name of the server
64
+ # @return [Boolean] true if valid, false otherwise
65
+ def validate_server_config(config, server_name)
66
+ return true if config.is_a?(Hash)
67
+
68
+ @logger.warn("Configuration for server '#{server_name}' is not an object; skipping.")
69
+ false
70
+ end
71
+
72
+ # Process a single server configuration
73
+ # @param config [Hash] server configuration to process
74
+ # @param server_name [String] name of the server
75
+ # @return [Hash, nil] processed configuration or nil if invalid
76
+ def process_server_config(config, server_name)
77
+ type = determine_server_type(config, server_name)
78
+ return nil unless type
79
+
80
+ clean = { type: type.to_s }
81
+ case type.to_s
82
+ when 'stdio'
83
+ parse_stdio_config(clean, config, server_name)
84
+ when 'sse'
85
+ return nil unless parse_sse_config(clean, config, server_name)
86
+ else
87
+ @logger.warn("Unrecognized type '#{type}' for server '#{server_name}'; skipping.")
88
+ return nil
89
+ end
90
+
91
+ clean
92
+ end
93
+
94
+ # Determine the type of server from its configuration
95
+ # @param config [Hash] server configuration
96
+ # @param server_name [String] name of the server for logging
97
+ # @return [String, nil] determined server type or nil if cannot be determined
98
+ def determine_server_type(config, server_name)
99
+ type = config['type']
100
+ return type if type
101
+
102
+ inferred_type = if config.key?('command') || config.key?('args') || config.key?('env')
103
+ 'stdio'
104
+ elsif config.key?('url')
105
+ 'sse'
106
+ end
107
+
108
+ if inferred_type
109
+ @logger.warn("'type' not specified for server '#{server_name}', inferring as '#{inferred_type}'.")
110
+ return inferred_type
111
+ end
112
+
113
+ @logger.warn("Could not determine type for server '#{server_name}' (missing 'command' or 'url'); skipping.")
114
+ nil
115
+ end
116
+
117
+ private
118
+
119
+ # Parse stdio-specific configuration
120
+ # @param clean [Hash] clean configuration hash to update
121
+ # @param config [Hash] raw configuration from JSON
122
+ # @param server_name [String] name of the server for error reporting
123
+ def parse_stdio_config(clean, config, server_name)
124
+ # Command is required
125
+ cmd = config['command']
126
+ unless cmd.is_a?(String)
127
+ @logger.warn("'command' for server '#{server_name}' is not a string; converting to string.")
128
+ cmd = cmd.to_s
129
+ end
130
+
131
+ # Args are optional
132
+ args = config['args']
133
+ if args.is_a?(Array)
134
+ args = args.map(&:to_s)
135
+ elsif args
136
+ @logger.warn("'args' for server '#{server_name}' is not an array; treating as single argument.")
137
+ args = [args.to_s]
138
+ else
139
+ args = []
140
+ end
141
+
142
+ # Environment variables are optional
143
+ env = config['env']
144
+ env = env.is_a?(Hash) ? env.transform_keys(&:to_s) : {}
145
+
146
+ # Update clean config
147
+ clean[:command] = cmd
148
+ clean[:args] = args
149
+ clean[:env] = env
150
+ end
151
+
152
+ # Parse SSE-specific configuration
153
+ # @param clean [Hash] clean configuration hash to update
154
+ # @param config [Hash] raw configuration from JSON
155
+ # @param server_name [String] name of the server for error reporting
156
+ # @return [Boolean] true if parsing succeeded, false if required elements are missing
157
+ def parse_sse_config(clean, config, server_name)
158
+ # URL is required
159
+ source = config['url']
160
+ unless source
161
+ @logger.warn("SSE server '#{server_name}' is missing required 'url' property; skipping.")
162
+ return false
163
+ end
164
+
165
+ unless source.is_a?(String)
166
+ @logger.warn("'url' for server '#{server_name}' is not a string; converting to string.")
167
+ source = source.to_s
168
+ end
169
+
170
+ # Headers are optional
171
+ headers = config['headers']
172
+ headers = headers.is_a?(Hash) ? headers.transform_keys(&:to_s) : {}
173
+
174
+ # Update clean config
175
+ clean[:url] = source
176
+ clean[:headers] = headers
177
+ true
178
+ end
179
+
180
+ # Filter out reserved keys from configuration objects
181
+ # @param data [Hash] configuration data
182
+ # @return [Hash] filtered configuration data
183
+ def filter_reserved_keys(data)
184
+ return data unless data.is_a?(Hash)
185
+
186
+ result = {}
187
+ data.each do |key, value|
188
+ # Skip reserved keys at server level
189
+ next if RESERVED_KEYS.include?(key)
190
+
191
+ # If value is a hash, recursively filter its keys too
192
+ if value.is_a?(Hash)
193
+ filtered_value = value.dup
194
+ RESERVED_KEYS.each { |reserved| filtered_value.delete(reserved) }
195
+ result[key] = filtered_value
196
+ else
197
+ result[key] = value
198
+ end
199
+ end
200
+ result
201
+ end
202
+ end
203
+ end
@@ -48,6 +48,7 @@ module MCPClient
48
48
  @connection_established = false
49
49
  @connection_cv = @mutex.new_cond
50
50
  @initialized = false
51
+ @auth_error = nil
51
52
  # Whether to use SSE transport; may disable if handshake fails
52
53
  @use_sse = true
53
54
  end
@@ -131,25 +132,30 @@ module MCPClient
131
132
  # @return [Boolean] true if connection was successful
132
133
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
133
134
  def connect
134
- @mutex.synchronize do
135
- return true if @connection_established
135
+ return true if @mutex.synchronize { @connection_established }
136
136
 
137
- # Start SSE listener using Faraday HTTP client
137
+ begin
138
138
  start_sse_thread
139
+ effective_timeout = [@read_timeout || 30, 30].min
140
+ wait_for_connection(timeout: effective_timeout)
141
+ true
142
+ rescue MCPClient::Errors::ConnectionError => e
143
+ cleanup
144
+ # Check for stored auth error first, as it's more specific
145
+ auth_error = @mutex.synchronize { @auth_error }
146
+ raise MCPClient::Errors::ConnectionError, auth_error if auth_error
147
+
148
+ raise MCPClient::Errors::ConnectionError, e.message if e.message.include?('Authorization failed')
149
+
150
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
151
+ rescue StandardError => e
152
+ cleanup
153
+ # Check for stored auth error
154
+ auth_error = @mutex.synchronize { @auth_error }
155
+ raise MCPClient::Errors::ConnectionError, auth_error if auth_error
139
156
 
140
- timeout = 10
141
- success = @connection_cv.wait(timeout) { @connection_established }
142
-
143
- unless success
144
- cleanup
145
- raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
146
- end
147
-
148
- @connection_established
157
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
149
158
  end
150
- rescue StandardError => e
151
- cleanup
152
- raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
153
159
  end
154
160
 
155
161
  # Clean up the server connection
@@ -171,6 +177,7 @@ module MCPClient
171
177
  @tools = nil
172
178
  @connection_established = false
173
179
  @sse_connected = false
180
+ # Don't clear auth error as we need it for reporting the correct error
174
181
  end
175
182
  end
176
183
 
@@ -222,6 +229,25 @@ module MCPClient
222
229
 
223
230
  private
224
231
 
232
+ # Wait for SSE connection to be established with periodic checks
233
+ # @param timeout [Integer] Maximum time to wait in seconds
234
+ # @raise [MCPClient::Errors::ConnectionError] if timeout expires
235
+ def wait_for_connection(timeout:)
236
+ @mutex.synchronize do
237
+ deadline = Time.now + timeout
238
+
239
+ until @connection_established
240
+ remaining = [1, deadline - Time.now].min
241
+ break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
242
+ end
243
+
244
+ unless @connection_established
245
+ cleanup
246
+ raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
247
+ end
248
+ end
249
+ end
250
+
225
251
  # Ensure SSE initialization handshake has been performed
226
252
  def ensure_initialized
227
253
  return if @initialized
@@ -253,34 +279,85 @@ module MCPClient
253
279
  @capabilities = result['capabilities'] if result.key?('capabilities')
254
280
  end
255
281
 
282
+ # Set up the SSE connection
283
+ # @param uri [URI] The parsed base URL
284
+ # @return [Faraday::Connection] The configured Faraday connection
285
+ def setup_sse_connection(uri)
286
+ sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
287
+
288
+ @sse_conn ||= Faraday.new(url: sse_base) do |f|
289
+ f.options.open_timeout = 10
290
+ f.options.timeout = nil
291
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
292
+ f.adapter Faraday.default_adapter
293
+ end
294
+
295
+ # Use response handling with status check
296
+ @sse_conn.builder.use Faraday::Response::RaiseError
297
+ @sse_conn
298
+ end
299
+
300
+ # Handle authorization errors from Faraday
301
+ # @param error [Faraday::Error] The authorization error
302
+ # @raise [MCPClient::Errors::ConnectionError] with appropriate message
303
+ def handle_sse_auth_error(error)
304
+ error_message = "Authorization failed: HTTP #{error.response[:status]}"
305
+ @logger.error(error_message)
306
+
307
+ @mutex.synchronize do
308
+ @auth_error = error_message
309
+ @connection_established = false
310
+ @connection_cv.broadcast
311
+ end
312
+ raise MCPClient::Errors::ConnectionError, error_message
313
+ end
314
+
315
+ # Reset connection state and signal waiting threads
316
+ def reset_connection_state
317
+ @mutex.synchronize do
318
+ @connection_established = false
319
+ @connection_cv.broadcast
320
+ end
321
+ end
322
+
256
323
  # Start the SSE thread to listen for events
257
324
  def start_sse_thread
258
325
  return if @sse_thread&.alive?
259
326
 
260
327
  @sse_thread = Thread.new do
261
328
  uri = URI.parse(@base_url)
262
- sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
263
329
  sse_path = uri.request_uri
330
+ conn = setup_sse_connection(uri)
264
331
 
265
- @sse_conn ||= Faraday.new(url: sse_base) do |f|
266
- f.options.open_timeout = 10
267
- f.options.timeout = nil
268
- f.adapter Faraday.default_adapter
332
+ # Reset connection state
333
+ @mutex.synchronize do
334
+ @sse_connected = false
335
+ @connection_established = false
269
336
  end
270
337
 
271
- @sse_conn.get(sse_path) do |req|
272
- @headers.each { |k, v| req.headers[k] = v }
273
- req.options.on_data = proc do |chunk, _bytes|
274
- @logger.debug("SSE chunk received: #{chunk.inspect}")
275
- process_sse_chunk(chunk.dup)
338
+ begin
339
+ conn.get(sse_path) do |req|
340
+ @headers.each { |k, v| req.headers[k] = v }
341
+
342
+ req.options.on_data = proc do |chunk, _bytes|
343
+ process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
344
+ end
276
345
  end
346
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
347
+ handle_sse_auth_error(e)
348
+ rescue Faraday::Error => e
349
+ @logger.error("Failed SSE connection: #{e.message}")
350
+ raise
277
351
  end
278
- rescue StandardError
279
- # On any SSE thread error, signal connection established to unblock connect
280
- @mutex.synchronize do
281
- @connection_established = true
282
- @connection_cv.broadcast
283
- end
352
+ rescue MCPClient::Errors::ConnectionError => e
353
+ # Re-raise connection errors to propagate them
354
+ # Signal connect method to stop waiting
355
+ reset_connection_state
356
+ raise e
357
+ rescue StandardError => e
358
+ @logger.error("SSE connection error: #{e.message}")
359
+ # Signal connect method to avoid deadlock
360
+ reset_connection_state
284
361
  ensure
285
362
  @mutex.synchronize { @sse_connected = false }
286
363
  end
@@ -290,18 +367,126 @@ module MCPClient
290
367
  # @param chunk [String] the chunk to process
291
368
  def process_sse_chunk(chunk)
292
369
  @logger.debug("Processing SSE chunk: #{chunk.inspect}")
293
- local_buffer = nil
294
370
 
371
+ # Check for direct JSON error responses (which aren't proper SSE events)
372
+ if chunk.start_with?('{') && chunk.include?('"error"') &&
373
+ (chunk.include?('Unauthorized') || chunk.include?('authentication'))
374
+ begin
375
+ data = JSON.parse(chunk)
376
+ if data['error']
377
+ error_message = data['error']['message'] || 'Unknown server error'
378
+
379
+ @mutex.synchronize do
380
+ @auth_error = "Authorization failed: #{error_message}"
381
+
382
+ @connection_established = false
383
+ @connection_cv.broadcast
384
+ end
385
+
386
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
387
+ end
388
+ rescue JSON::ParserError
389
+ # Not valid JSON, process normally
390
+ end
391
+ end
392
+
393
+ event_buffers = nil
295
394
  @mutex.synchronize do
296
395
  @buffer += chunk
297
396
 
397
+ # Extract all complete events from the buffer
398
+ event_buffers = []
298
399
  while (event_end = @buffer.index("\n\n"))
299
400
  event_data = @buffer.slice!(0, event_end + 2)
300
- local_buffer = event_data
401
+ event_buffers << event_data
301
402
  end
302
403
  end
303
404
 
304
- parse_and_handle_sse_event(local_buffer) if local_buffer
405
+ # Process extracted events outside the mutex to avoid deadlocks
406
+ event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
407
+ end
408
+
409
+ # Handle SSE endpoint event
410
+ # @param data [String] The endpoint path
411
+ def handle_endpoint_event(data)
412
+ @mutex.synchronize do
413
+ @rpc_endpoint = data
414
+ @sse_connected = true
415
+ @connection_established = true
416
+ @connection_cv.broadcast
417
+ end
418
+ end
419
+
420
+ # Check if the error represents an authorization error
421
+ # @param error_message [String] The error message from the server
422
+ # @param error_code [Integer, nil] The error code if available
423
+ # @return [Boolean] True if it's an authorization error
424
+ def authorization_error?(error_message, error_code)
425
+ return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
426
+ return true if [401, -32_000].include?(error_code)
427
+
428
+ false
429
+ end
430
+
431
+ # Handle authorization error in SSE message
432
+ # @param error_message [String] The error message from the server
433
+ def handle_sse_auth_error_message(error_message)
434
+ @mutex.synchronize do
435
+ @auth_error = "Authorization failed: #{error_message}"
436
+ @connection_established = false
437
+ @connection_cv.broadcast
438
+ end
439
+
440
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
441
+ end
442
+
443
+ # Process error messages in SSE responses
444
+ # @param data [Hash] The parsed SSE message data
445
+ def process_error_in_message(data)
446
+ return unless data['error']
447
+
448
+ error_message = data['error']['message'] || 'Unknown server error'
449
+ error_code = data['error']['code']
450
+
451
+ # Handle unauthorized errors (close connection immediately)
452
+ handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
453
+
454
+ @logger.error("Server error: #{error_message}")
455
+ true # Error was processed
456
+ end
457
+
458
+ # Process JSON-RPC notifications
459
+ # @param data [Hash] The parsed SSE message data
460
+ # @return [Boolean] True if a notification was processed
461
+ def process_notification(data)
462
+ return false unless data['method'] && !data.key?('id')
463
+
464
+ @notification_callback&.call(data['method'], data['params'])
465
+ true
466
+ end
467
+
468
+ # Process JSON-RPC responses
469
+ # @param data [Hash] The parsed SSE message data
470
+ # @return [Boolean] True if a response was processed
471
+ def process_response(data)
472
+ return false unless data['id']
473
+
474
+ @mutex.synchronize do
475
+ # Store tools data if present
476
+ @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
477
+
478
+ # Store response for the waiting request
479
+ if data['error']
480
+ @sse_results[data['id']] = {
481
+ 'isError' => true,
482
+ 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
483
+ }
484
+ elsif data['result']
485
+ @sse_results[data['id']] = data['result']
486
+ end
487
+ end
488
+
489
+ true
305
490
  end
306
491
 
307
492
  # Parse and handle an SSE event
@@ -312,38 +497,35 @@ module MCPClient
312
497
 
313
498
  case event[:event]
314
499
  when 'endpoint'
315
- ep = event[:data]
316
- @mutex.synchronize do
317
- @rpc_endpoint = ep
318
- @connection_established = true
319
- @connection_cv.broadcast
320
- end
500
+ handle_endpoint_event(event[:data])
501
+ when 'ping'
502
+ # Received ping event, no action needed
321
503
  when 'message'
322
- begin
323
- data = JSON.parse(event[:data])
324
- # Dispatch JSON-RPC notifications (no id, has method)
325
- if data['method'] && !data.key?('id')
326
- @notification_callback&.call(data['method'], data['params'])
327
- return
328
- end
504
+ handle_message_event(event)
505
+ end
506
+ end
329
507
 
330
- @mutex.synchronize do
331
- @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
332
-
333
- if data['id']
334
- if data['error']
335
- @sse_results[data['id']] = {
336
- 'isError' => true,
337
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
338
- }
339
- elsif data['result']
340
- @sse_results[data['id']] = data['result']
341
- end
342
- end
343
- end
344
- rescue JSON::ParserError
345
- nil
346
- end
508
+ # Handle a message event from SSE
509
+ # @param event [Hash] The parsed SSE event
510
+ def handle_message_event(event)
511
+ return if event[:data].empty?
512
+
513
+ begin
514
+ data = JSON.parse(event[:data])
515
+
516
+ # Process the message in order of precedence
517
+ return if process_error_in_message(data)
518
+
519
+ return if process_notification(data)
520
+
521
+ process_response(data)
522
+ rescue MCPClient::Errors::ConnectionError
523
+ # Re-raise connection errors to propagate to the calling code
524
+ raise
525
+ rescue JSON::ParserError => e
526
+ @logger.warn("Failed to parse JSON from event data: #{e.message}")
527
+ rescue StandardError => e
528
+ @logger.error("Error processing SSE event: #{e.message}")
347
529
  end
348
530
  end
349
531
 
@@ -351,14 +533,19 @@ module MCPClient
351
533
  # @param event_data [String] the event data to parse
352
534
  # @return [Hash, nil] the parsed event, or nil if the event is invalid
353
535
  def parse_sse_event(event_data)
354
- @logger.debug("Parsing SSE event data: #{event_data.inspect}")
355
536
  event = { event: 'message', data: '', id: nil }
356
537
  data_lines = []
538
+ has_content = false
357
539
 
358
540
  event_data.each_line do |line|
359
541
  line = line.chomp
360
542
  next if line.empty?
361
543
 
544
+ # Skip SSE comments (lines starting with colon)
545
+ next if line.start_with?(':')
546
+
547
+ has_content = true
548
+
362
549
  if line.start_with?('event:')
363
550
  event[:event] = line[6..].strip
364
551
  elsif line.start_with?('data:')
@@ -369,8 +556,9 @@ module MCPClient
369
556
  end
370
557
 
371
558
  event[:data] = data_lines.join("\n")
372
- @logger.debug("Parsed SSE event: #{event.inspect}")
373
- event[:data].empty? ? nil : event
559
+
560
+ # Return the event even if data is empty as long as we had non-comment content
561
+ has_content ? event : nil
374
562
  end
375
563
 
376
564
  # Request the tools list using JSON-RPC
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.5.0'
5
+ VERSION = '0.5.2'
6
6
  end
data/lib/mcp_client.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'mcp_client/server_sse'
9
9
  require_relative 'mcp_client/server_factory'
10
10
  require_relative 'mcp_client/client'
11
11
  require_relative 'mcp_client/version'
12
+ require_relative 'mcp_client/config_parser'
12
13
 
13
14
  # Model Context Protocol (MCP) Client module
14
15
  # Provides a standardized way for agents to communicate with external tools and services
@@ -16,9 +17,31 @@ require_relative 'mcp_client/version'
16
17
  module MCPClient
17
18
  # Create a new MCPClient client
18
19
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
20
+ # @param server_definition_file [String, nil] optional path to a JSON file defining server configurations
21
+ # The JSON may be a single server object or an array of server objects.
19
22
  # @return [MCPClient::Client] new client instance
20
- def self.create_client(mcp_server_configs: [])
21
- MCPClient::Client.new(mcp_server_configs: mcp_server_configs)
23
+ def self.create_client(mcp_server_configs: [], server_definition_file: nil)
24
+ require 'json'
25
+ # Start with any explicit configs provided
26
+ configs = Array(mcp_server_configs)
27
+ # Load additional configs from a JSON file if specified
28
+ if server_definition_file
29
+ # Parse JSON definitions into clean config hashes
30
+ parser = MCPClient::ConfigParser.new(server_definition_file)
31
+ parsed = parser.parse
32
+ parsed.each_value do |cfg|
33
+ case cfg[:type].to_s
34
+ when 'stdio'
35
+ # Build command list with args
36
+ cmd_list = [cfg[:command]] + Array(cfg[:args])
37
+ configs << MCPClient.stdio_config(command: cmd_list)
38
+ when 'sse'
39
+ # Use 'url' from parsed config as 'base_url' for SSE config
40
+ configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {})
41
+ end
42
+ end
43
+ end
44
+ MCPClient::Client.new(mcp_server_configs: configs)
22
45
  end
23
46
 
24
47
  # Create a standard server configuration for stdio
@@ -35,13 +58,17 @@ module MCPClient
35
58
  # @param base_url [String] base URL for the server
36
59
  # @param headers [Hash] HTTP headers to include in requests
37
60
  # @param read_timeout [Integer] read timeout in seconds (default: 30)
61
+ # @param retries [Integer] number of retry attempts (default: 0)
62
+ # @param retry_backoff [Integer] backoff delay in seconds (default: 1)
38
63
  # @return [Hash] server configuration
39
- def self.sse_config(base_url:, headers: {}, read_timeout: 30)
64
+ def self.sse_config(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1)
40
65
  {
41
66
  type: 'sse',
42
67
  base_url: base_url,
43
68
  headers: headers,
44
- read_timeout: read_timeout
69
+ read_timeout: read_timeout,
70
+ retries: retries,
71
+ retry_backoff: retry_backoff
45
72
  }
46
73
  end
47
74
  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.0
4
+ version: 0.5.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-04-25 00:00:00.000000000 Z
11
+ date: 2025-05-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -106,6 +106,7 @@ files:
106
106
  - README.md
107
107
  - lib/mcp_client.rb
108
108
  - lib/mcp_client/client.rb
109
+ - lib/mcp_client/config_parser.rb
109
110
  - lib/mcp_client/errors.rb
110
111
  - lib/mcp_client/server_base.rb
111
112
  - lib/mcp_client/server_factory.rb