ruby-mcp-client 0.5.1 → 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: 16280a305c2b9cd19ecc2a71b8f10d3805644015f34f7eaec28d5846f12fa1db
4
- data.tar.gz: 48a791c3a88255c25078234819024dace993f7cfc564485f48680a8768b81238
3
+ metadata.gz: fbd3caf1e80f6c2caf625495072211b827ae08e3542fa33306537918d0b6eb5f
4
+ data.tar.gz: 1eea548ba10e7de3fec68a92d449e4552fde3c5920af045e2e828925dba2aad7
5
5
  SHA512:
6
- metadata.gz: d4155463bcc691725b3a88e29dbb7df75a5b05a9ca7693d12a4fcc6f43bb57df06966089042dcaebb6f9f0a3b429a1eaa3138bb478ad161aad98163eb6de2763
7
- data.tar.gz: 6acb25bd0f1c72d640570823c262f548bda1f09322c137789ad36d5950dc80eda6d66e707b0c69229805ffdb575aea086ae999747d74aef9304ad65c46e67039
6
+ metadata.gz: e8eae783bd1e6a4db013a934e0686641befaec435fb288531ce85899efc0a66023d3d174dedffae21f75089f406f6368f286f13644701fbdee747492b795535b
7
+ data.tar.gz: 534cc5c88f8f3737c5fc3e684174980af74ac18398532e4b5698490b451b5fc5ff0831ca48639119445c71f4afa94ba3fca94ff81edfa03d1e3ba34a73c4f1b1
data/README.md CHANGED
@@ -298,7 +298,7 @@ You can define MCP server configurations in JSON files for easier management:
298
298
  }
299
299
  ```
300
300
 
301
- A simpler example used in the Playwright demo (found in `examples/playwright_server_definition.json`):
301
+ A simpler example used in the Playwright demo (found in `examples/sample_server_definition.json`):
302
302
 
303
303
  ```json
304
304
  {
@@ -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.1'
5
+ VERSION = '0.5.2'
6
6
  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.1
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-26 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