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.
@@ -11,6 +11,14 @@ module MCPClient
11
11
  # Implementation of MCP server that communicates via Server-Sent Events (SSE)
12
12
  # Useful for communicating with remote MCP servers over HTTP
13
13
  class ServerSSE < ServerBase
14
+ require 'mcp_client/server_sse/sse_parser'
15
+ require 'mcp_client/server_sse/json_rpc_transport'
16
+
17
+ include SseParser
18
+ include JsonRpcTransport
19
+ require 'mcp_client/server_sse/reconnect_monitor'
20
+
21
+ include ReconnectMonitor
14
22
  # Ratio of close_after timeout to ping interval
15
23
  CLOSE_AFTER_PING_RATIO = 2.5
16
24
 
@@ -23,6 +31,14 @@ module MCPClient
23
31
  MAX_RECONNECT_DELAY = 30
24
32
  JITTER_FACTOR = 0.25
25
33
 
34
+ # @!attribute [r] base_url
35
+ # @return [String] The base URL of the MCP server
36
+ # @!attribute [r] tools
37
+ # @return [Array<MCPClient::Tool>, nil] List of available tools (nil if not fetched yet)
38
+ # @!attribute [r] server_info
39
+ # @return [Hash, nil] Server information from initialize response
40
+ # @!attribute [r] capabilities
41
+ # @return [Hash, nil] Server capabilities from initialize response
26
42
  attr_reader :base_url, :tools, :server_info, :capabilities
27
43
 
28
44
  # @param base_url [String] The base URL of the MCP server
@@ -123,26 +139,16 @@ module MCPClient
123
139
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
124
140
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
125
141
  def call_tool(tool_name, parameters)
126
- if !@connection_established || !@sse_connected
127
- # Try to reconnect
128
- @logger.debug('Connection not active, attempting to reconnect before tool call')
129
- cleanup
130
- connect
131
- end
132
-
133
- # Use rpc_request to handle the actual RPC call
134
- begin
135
- rpc_request('tools/call', {
136
- name: tool_name,
137
- arguments: parameters
138
- })
139
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
140
- # Re-raise connection/transport errors directly to match test expectations
141
- raise
142
- rescue StandardError => e
143
- # For all other errors, wrap in ToolCallError
144
- raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
145
- end
142
+ rpc_request('tools/call', {
143
+ name: tool_name,
144
+ arguments: parameters
145
+ })
146
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
147
+ # Re-raise connection/transport errors directly to match test expectations
148
+ raise
149
+ rescue StandardError => e
150
+ # For all other errors, wrap in ToolCallError
151
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
146
152
  end
147
153
 
148
154
  # Connect to the MCP server over HTTP/HTTPS with SSE
@@ -232,337 +238,12 @@ module MCPClient
232
238
  end
233
239
  end
234
240
 
235
- # Generic JSON-RPC request: send method with params and return result
236
- # @param method [String] JSON-RPC method name
237
- # @param params [Hash] parameters for the request
238
- # @return [Object] result from JSON-RPC response
239
- # @raise [MCPClient::Errors::ServerError] if server returns an error
240
- # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
241
- # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
242
- # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
243
- def rpc_request(method, params = {})
244
- ensure_initialized
245
- with_retry do
246
- request_id = @mutex.synchronize { @request_id += 1 }
247
- request = { jsonrpc: '2.0', id: request_id, method: method, params: params }
248
- send_jsonrpc_request(request)
249
- end
250
- end
251
-
252
- # Send a JSON-RPC notification (no response expected)
253
- # @param method [String] JSON-RPC method name
254
- # @param params [Hash] parameters for the notification
255
- # @return [void]
256
- def rpc_notify(method, params = {})
257
- ensure_initialized
258
- uri = URI.parse(@base_url)
259
- base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
260
- rpc_ep = @mutex.synchronize { @rpc_endpoint }
261
- @rpc_conn ||= Faraday.new(url: base) do |f|
262
- f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
263
- f.options.open_timeout = @read_timeout
264
- f.options.timeout = @read_timeout
265
- f.adapter Faraday.default_adapter
266
- end
267
- response = @rpc_conn.post(rpc_ep) do |req|
268
- req.headers['Content-Type'] = 'application/json'
269
- req.headers['Accept'] = 'application/json'
270
- (@headers.dup.tap do |h|
271
- h.delete('Accept')
272
- h.delete('Cache-Control')
273
- end).each do |k, v|
274
- req.headers[k] = v
275
- end
276
- req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
277
- end
278
- unless response.success?
279
- raise MCPClient::Errors::ServerError, "Notification failed: #{response.status} #{response.reason_phrase}"
280
- end
281
- rescue StandardError => e
282
- raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
283
- end
284
-
285
- # Ping the server to keep the connection alive
286
- # @return [Hash] the result of the ping request
287
- # @raise [MCPClient::Errors::ToolCallError] if ping times out or fails
288
- # @raise [MCPClient::Errors::TransportError] if there's a connection error
289
- # @raise [MCPClient::Errors::ServerError] if the server returns an error
290
- def ping
291
- rpc_request('ping')
292
- end
293
-
294
241
  private
295
242
 
296
- # Start the activity monitor thread that handles connection maintenance
297
- #
298
- # This thread is responsible for three main tasks:
299
- # 1. Sending pings after inactivity (@ping_interval seconds)
300
- # 2. Closing idle connections after prolonged inactivity (@close_after seconds)
301
- # 3. Automatically reconnecting after multiple ping failures
302
- #
303
- # Reconnection parameters:
304
- # - @consecutive_ping_failures: Counter for consecutive failed pings
305
- # - @max_ping_failures: Threshold to trigger reconnection (default: 3)
306
- # - @reconnect_attempts: Counter for reconnection attempts
307
- # - @max_reconnect_attempts: Maximum retry limit (default: 5)
308
- def start_activity_monitor
309
- return if @activity_timer_thread&.alive?
310
-
311
- @mutex.synchronize do
312
- @last_activity_time = Time.now
313
- @consecutive_ping_failures = 0
314
- @max_ping_failures = DEFAULT_MAX_PING_FAILURES # Reconnect after this many failures
315
- @reconnect_attempts = 0
316
- @max_reconnect_attempts = DEFAULT_MAX_RECONNECT_ATTEMPTS # Give up after this many reconnect attempts
317
- end
318
-
319
- @activity_timer_thread = Thread.new do
320
- activity_monitor_loop
321
- rescue StandardError => e
322
- @logger.error("Activity monitor error: #{e.message}")
323
- end
324
- end
325
-
326
- # Helper method to check if connection is active
327
- # @return [Boolean] true if connection is established and SSE is connected
328
- def connection_active?
329
- @mutex.synchronize { @connection_established && @sse_connected }
330
- end
331
-
332
- # Main activity monitoring loop
333
- def activity_monitor_loop
334
- loop do
335
- sleep 1 # Check every second
336
-
337
- # Exit if connection is not active
338
- unless connection_active?
339
- @logger.debug('Activity monitor exiting: connection no longer active')
340
- return
341
- end
342
-
343
- # Initialize variables if they don't exist yet
344
- @mutex.synchronize do
345
- @consecutive_ping_failures ||= 0
346
- @reconnect_attempts ||= 0
347
- @max_ping_failures ||= DEFAULT_MAX_PING_FAILURES
348
- @max_reconnect_attempts ||= DEFAULT_MAX_RECONNECT_ATTEMPTS
349
- end
350
-
351
- # Check if connection was closed after our check
352
- return unless connection_active?
353
-
354
- # Get time since last activity
355
- time_since_activity = Time.now - @last_activity_time
356
-
357
- # Handle inactivity closure
358
- if @close_after && time_since_activity >= @close_after
359
- @logger.info("Closing connection due to inactivity (#{time_since_activity.round(1)}s)")
360
- cleanup
361
- return
362
- end
363
-
364
- # Handle ping if needed
365
- next unless @ping_interval && time_since_activity >= @ping_interval
366
- return unless connection_active?
367
-
368
- # Determine if we should reconnect or ping
369
- if @consecutive_ping_failures >= @max_ping_failures
370
- attempt_reconnection
371
- else
372
- attempt_ping
373
- end
374
- end
375
- end
376
-
377
- # This section intentionally removed, as these methods were consolidated into activity_monitor_loop
378
-
379
- # Attempt to reconnect when consecutive pings have failed
380
- # @return [void]
381
- def attempt_reconnection
382
- if @reconnect_attempts < @max_reconnect_attempts
383
- begin
384
- # Calculate backoff delay with jitter to prevent thundering herd
385
- base_delay = BASE_RECONNECT_DELAY * (2**@reconnect_attempts)
386
- jitter = rand * JITTER_FACTOR * base_delay # Add randomness to prevent thundering herd
387
- backoff_delay = [base_delay + jitter, MAX_RECONNECT_DELAY].min
388
-
389
- reconnect_msg = "Attempting to reconnect (attempt #{@reconnect_attempts + 1}/#{@max_reconnect_attempts}) "
390
- reconnect_msg += "after #{@consecutive_ping_failures} consecutive ping failures. "
391
- reconnect_msg += "Waiting #{backoff_delay.round(2)}s before reconnect..."
392
- @logger.warn(reconnect_msg)
393
- sleep(backoff_delay)
394
-
395
- # Close existing connection
396
- cleanup
397
-
398
- # Try to reconnect
399
- connect
400
- @logger.info('Successfully reconnected after ping failures')
401
-
402
- # Reset counters
403
- @mutex.synchronize do
404
- @consecutive_ping_failures = 0
405
- @reconnect_attempts += 1
406
- @last_activity_time = Time.now
407
- end
408
- rescue StandardError => e
409
- @logger.error("Failed to reconnect after ping failures: #{e.message}")
410
- @mutex.synchronize { @reconnect_attempts += 1 }
411
- end
412
- else
413
- # We've exceeded max reconnect attempts
414
- @logger.error("Exceeded maximum reconnection attempts (#{@max_reconnect_attempts}). Closing connection.")
415
- cleanup
416
- end
417
- end
418
-
419
- # Attempt to ping the server
420
- def attempt_ping
421
- unless connection_active?
422
- @logger.debug('Skipping ping - connection not active')
423
- return
424
- end
425
-
426
- time_since = Time.now - @last_activity_time
427
- @logger.debug("Sending ping after #{time_since.round(1)}s of inactivity")
428
-
429
- begin
430
- ping
431
- @mutex.synchronize do
432
- @last_activity_time = Time.now
433
- @consecutive_ping_failures = 0 # Reset counter on successful ping
434
- end
435
- rescue StandardError => e
436
- # Check if connection is still active before counting as failure
437
- unless connection_active?
438
- @logger.debug("Ignoring ping failure - connection already closed: #{e.message}")
439
- return
440
- end
441
- handle_ping_failure(e)
442
- end
443
- end
444
-
445
- # Handle ping failure
446
- # @param error [StandardError] the error that occurred during ping
447
- def handle_ping_failure(error)
448
- @mutex.synchronize { @consecutive_ping_failures += 1 }
449
- consecutive_failures = @consecutive_ping_failures
450
-
451
- if consecutive_failures == 1
452
- # Log full error on first failure
453
- @logger.error("Error sending ping: #{error.message}")
454
- else
455
- # Log more concise message on subsequent failures
456
- error_msg = error.message.split("\n").first
457
- @logger.warn("Ping failed (#{consecutive_failures}/#{@max_ping_failures}): #{error_msg}")
458
- end
459
- end
460
-
461
- # Record activity to reset the inactivity timer
462
- def record_activity
463
- @mutex.synchronize { @last_activity_time = Time.now }
464
- end
465
-
466
- # Wait for SSE connection to be established with periodic checks
467
- # @param timeout [Integer] Maximum time to wait in seconds
468
- # @raise [MCPClient::Errors::ConnectionError] if timeout expires or auth error
469
- def wait_for_connection(timeout:)
470
- @mutex.synchronize do
471
- deadline = Time.now + timeout
472
-
473
- until @connection_established
474
- remaining = [1, deadline - Time.now].min
475
- break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
476
- end
477
-
478
- # Check for auth error first
479
- raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
480
-
481
- unless @connection_established
482
- cleanup
483
- # Create more specific message for timeout
484
- error_msg = "Failed to connect to MCP server at #{@base_url}"
485
- error_msg += ': Timed out waiting for SSE connection to be established'
486
- raise MCPClient::Errors::ConnectionError, error_msg
487
- end
488
- end
489
- end
490
-
491
- # Ensure SSE initialization handshake has been performed
492
- def ensure_initialized
493
- return if @initialized
494
-
495
- connect
496
- perform_initialize
497
-
498
- @initialized = true
499
- end
500
-
501
- # Perform JSON-RPC initialize handshake with the MCP server
502
- def perform_initialize
503
- request_id = @mutex.synchronize { @request_id += 1 }
504
- json_rpc_request = {
505
- jsonrpc: '2.0',
506
- id: request_id,
507
- method: 'initialize',
508
- params: {
509
- 'protocolVersion' => MCPClient::VERSION,
510
- 'capabilities' => {},
511
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
512
- }
513
- }
514
- @logger.debug("Performing initialize RPC: #{json_rpc_request}")
515
- result = send_jsonrpc_request(json_rpc_request)
516
- return unless result.is_a?(Hash)
517
-
518
- @server_info = result['serverInfo'] if result.key?('serverInfo')
519
- @capabilities = result['capabilities'] if result.key?('capabilities')
520
- end
521
-
522
- # Set up the SSE connection
523
- # @param uri [URI] The parsed base URL
524
- # @return [Faraday::Connection] The configured Faraday connection
525
- def setup_sse_connection(uri)
526
- sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
527
-
528
- @sse_conn ||= Faraday.new(url: sse_base) do |f|
529
- f.options.open_timeout = 10
530
- f.options.timeout = nil
531
- f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
532
- f.adapter Faraday.default_adapter
533
- end
534
-
535
- # Use response handling with status check
536
- @sse_conn.builder.use Faraday::Response::RaiseError
537
- @sse_conn
538
- end
539
-
540
- # Handle authorization errors from Faraday
541
- # @param error [Faraday::Error] The authorization error
542
- # Sets the auth error state but doesn't raise the exception directly
543
- # This allows the main thread to handle the error in a consistent way
544
- def handle_sse_auth_error(error)
545
- error_message = "Authorization failed: HTTP #{error.response[:status]}"
546
- @logger.error(error_message)
547
-
548
- @mutex.synchronize do
549
- @auth_error = error_message
550
- @connection_established = false
551
- @connection_cv.broadcast
552
- end
553
- # Don't raise here - the main thread will check @auth_error and raise appropriately
554
- end
555
-
556
- # Reset connection state and signal waiting threads
557
- def reset_connection_state
558
- @mutex.synchronize do
559
- @connection_established = false
560
- @connection_cv.broadcast
561
- end
562
- end
563
-
564
243
  # Start the SSE thread to listen for events
565
244
  # This thread handles the long-lived Server-Sent Events connection
245
+ # @return [Thread] the SSE thread
246
+ # @private
566
247
  def start_sse_thread
567
248
  return if @sse_thread&.alive?
568
249
 
@@ -572,6 +253,8 @@ module MCPClient
572
253
  end
573
254
 
574
255
  # Handle the SSE connection in a separate method to reduce method size
256
+ # @return [void]
257
+ # @private
575
258
  def handle_sse_connection
576
259
  uri = URI.parse(@base_url)
577
260
  sse_path = uri.request_uri
@@ -593,6 +276,8 @@ module MCPClient
593
276
  end
594
277
 
595
278
  # Reset SSE connection state
279
+ # @return [void]
280
+ # @private
596
281
  def reset_sse_connection_state
597
282
  @mutex.synchronize do
598
283
  @sse_connected = false
@@ -601,6 +286,10 @@ module MCPClient
601
286
  end
602
287
 
603
288
  # Establish SSE connection with error handling
289
+ # @param conn [Faraday::Connection] the Faraday connection to use
290
+ # @param sse_path [String] the SSE endpoint path
291
+ # @return [void]
292
+ # @private
604
293
  def establish_sse_connection(conn, sse_path)
605
294
  conn.get(sse_path) do |req|
606
295
  @headers.each { |k, v| req.headers[k] = v }
@@ -618,6 +307,9 @@ module MCPClient
618
307
  end
619
308
 
620
309
  # Handle auth errors from SSE response
310
+ # @param err [Faraday::Error] the authorization error
311
+ # @return [void]
312
+ # @private
621
313
  def handle_sse_auth_response_error(err)
622
314
  error_status = err.response ? err.response[:status] : 'unknown'
623
315
  auth_error = "Authorization failed: HTTP #{error_status}"
@@ -631,6 +323,10 @@ module MCPClient
631
323
  end
632
324
 
633
325
  # Handle connection failures in SSE
326
+ # @param err [Faraday::ConnectionFailed] the connection failure error
327
+ # @return [void]
328
+ # @raise [Faraday::ConnectionFailed] re-raises the original error
329
+ # @private
634
330
  def handle_sse_connection_failed(err)
635
331
  @logger.error("Failed to connect to MCP server at #{@base_url}: #{err.message}")
636
332
 
@@ -642,6 +338,10 @@ module MCPClient
642
338
  end
643
339
 
644
340
  # Handle general Faraday errors in SSE
341
+ # @param err [Faraday::Error] the general Faraday error
342
+ # @return [void]
343
+ # @raise [Faraday::Error] re-raises the original error
344
+ # @private
645
345
  def handle_sse_general_error(err)
646
346
  @logger.error("Failed SSE connection: #{err.message}")
647
347
 
@@ -698,21 +398,11 @@ module MCPClient
698
398
  event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
699
399
  end
700
400
 
701
- # Handle SSE endpoint event
702
- # @param data [String] The endpoint path
703
- def handle_endpoint_event(data)
704
- @mutex.synchronize do
705
- @rpc_endpoint = data
706
- @sse_connected = true
707
- @connection_established = true
708
- @connection_cv.broadcast
709
- end
710
- end
711
-
712
401
  # Check if the error represents an authorization error
713
402
  # @param error_message [String] The error message from the server
714
403
  # @param error_code [Integer, nil] The error code if available
715
404
  # @return [Boolean] True if it's an authorization error
405
+ # @private
716
406
  def authorization_error?(error_message, error_code)
717
407
  return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
718
408
  return true if [401, -32_000].include?(error_code)
@@ -722,6 +412,9 @@ module MCPClient
722
412
 
723
413
  # Handle authorization error in SSE message
724
414
  # @param error_message [String] The error message from the server
415
+ # @return [void]
416
+ # @raise [MCPClient::Errors::ConnectionError] with an authentication error message
417
+ # @private
725
418
  def handle_sse_auth_error_message(error_message)
726
419
  @mutex.synchronize do
727
420
  @auth_error = "Authorization failed: #{error_message}"
@@ -732,129 +425,10 @@ module MCPClient
732
425
  raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
733
426
  end
734
427
 
735
- # Process error messages in SSE responses
736
- # @param data [Hash] The parsed SSE message data
737
- def process_error_in_message(data)
738
- return unless data['error']
739
-
740
- error_message = data['error']['message'] || 'Unknown server error'
741
- error_code = data['error']['code']
742
-
743
- # Handle unauthorized errors (close connection immediately)
744
- handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
745
-
746
- @logger.error("Server error: #{error_message}")
747
- true # Error was processed
748
- end
749
-
750
- # Process JSON-RPC notifications
751
- # @param data [Hash] The parsed SSE message data
752
- # @return [Boolean] True if a notification was processed
753
- def process_notification(data)
754
- return false unless data['method'] && !data.key?('id')
755
-
756
- @notification_callback&.call(data['method'], data['params'])
757
- true
758
- end
759
-
760
- # Process JSON-RPC responses
761
- # @param data [Hash] The parsed SSE message data
762
- # @return [Boolean] True if a response was processed
763
- def process_response(data)
764
- return false unless data['id']
765
-
766
- @mutex.synchronize do
767
- # Store tools data if present
768
- @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
769
-
770
- # Store response for the waiting request
771
- if data['error']
772
- @sse_results[data['id']] = {
773
- 'isError' => true,
774
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
775
- }
776
- elsif data['result']
777
- @sse_results[data['id']] = data['result']
778
- end
779
- end
780
-
781
- true
782
- end
783
-
784
- # Parse and handle an SSE event
785
- # @param event_data [String] the event data to parse
786
- def parse_and_handle_sse_event(event_data)
787
- event = parse_sse_event(event_data)
788
- return if event.nil?
789
-
790
- case event[:event]
791
- when 'endpoint'
792
- handle_endpoint_event(event[:data])
793
- when 'ping'
794
- # Received ping event, no action needed
795
- when 'message'
796
- handle_message_event(event)
797
- end
798
- end
799
-
800
- # Handle a message event from SSE
801
- # @param event [Hash] The parsed SSE event
802
- def handle_message_event(event)
803
- return if event[:data].empty?
804
-
805
- begin
806
- data = JSON.parse(event[:data])
807
-
808
- # Process the message in order of precedence
809
- return if process_error_in_message(data)
810
-
811
- return if process_notification(data)
812
-
813
- process_response(data)
814
- rescue MCPClient::Errors::ConnectionError
815
- # Re-raise connection errors to propagate to the calling code
816
- raise
817
- rescue JSON::ParserError => e
818
- @logger.warn("Failed to parse JSON from event data: #{e.message}")
819
- rescue StandardError => e
820
- @logger.error("Error processing SSE event: #{e.message}")
821
- end
822
- end
823
-
824
- # Parse an SSE event
825
- # @param event_data [String] the event data to parse
826
- # @return [Hash, nil] the parsed event, or nil if the event is invalid
827
- def parse_sse_event(event_data)
828
- event = { event: 'message', data: '', id: nil }
829
- data_lines = []
830
- has_content = false
831
-
832
- event_data.each_line do |line|
833
- line = line.chomp
834
- next if line.empty?
835
-
836
- # Skip SSE comments (lines starting with colon)
837
- next if line.start_with?(':')
838
-
839
- has_content = true
840
-
841
- if line.start_with?('event:')
842
- event[:event] = line[6..].strip
843
- elsif line.start_with?('data:')
844
- data_lines << line[5..].strip
845
- elsif line.start_with?('id:')
846
- event[:id] = line[3..].strip
847
- end
848
- end
849
-
850
- event[:data] = data_lines.join("\n")
851
-
852
- # Return the event even if data is empty as long as we had non-comment content
853
- has_content ? event : nil
854
- end
855
-
856
428
  # Request the tools list using JSON-RPC
857
429
  # @return [Array<Hash>] the tools data
430
+ # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
431
+ # @private
858
432
  def request_tools_list
859
433
  @mutex.synchronize do
860
434
  return @tools_data if @tools_data
@@ -876,204 +450,5 @@ module MCPClient
876
450
 
877
451
  raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
878
452
  end
879
-
880
- # Helper: execute block with retry/backoff for transient errors
881
- # @yield block to execute
882
- # @return result of block
883
- def with_retry
884
- attempts = 0
885
- begin
886
- yield
887
- rescue MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT, Errno::ECONNRESET => e
888
- attempts += 1
889
- if attempts <= @max_retries
890
- delay = @retry_backoff * (2**(attempts - 1))
891
- @logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
892
- sleep(delay)
893
- retry
894
- end
895
- raise
896
- end
897
- end
898
-
899
- # Send a JSON-RPC request to the server and wait for result
900
- # @param request [Hash] the JSON-RPC request
901
- # @return [Hash] the result of the request
902
- # @raise [MCPClient::Errors::ConnectionError] if server connection is lost
903
- # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
904
- # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
905
- def send_jsonrpc_request(request)
906
- @logger.debug("Sending JSON-RPC request: #{request.to_json}")
907
- record_activity
908
-
909
- begin
910
- response = post_json_rpc_request(request)
911
-
912
- if @use_sse
913
- wait_for_sse_result(request)
914
- else
915
- parse_direct_response(response)
916
- end
917
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
918
- # Re-raise these errors directly
919
- raise
920
- rescue JSON::ParserError => e
921
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
922
- rescue Errno::ECONNREFUSED => e
923
- raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
924
- rescue StandardError => e
925
- method_name = request[:method] || request['method']
926
-
927
- # Format error message based on method
928
- if method_name == 'tools/call'
929
- # Extract tool name from parameters
930
- tool_name = request[:params][:name] || request['params']['name']
931
- raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
932
- elsif method_name == 'tools/list'
933
- raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
934
- else
935
- raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
936
- end
937
- end
938
- end
939
-
940
- # Post a JSON-RPC request to the server
941
- # @param request [Hash] the JSON-RPC request
942
- # @return [Faraday::Response] the HTTP response
943
- # @raise [MCPClient::Errors::ConnectionError] if connection fails
944
- def post_json_rpc_request(request)
945
- uri = URI.parse(@base_url)
946
- base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
947
- rpc_ep = @mutex.synchronize { @rpc_endpoint }
948
-
949
- @rpc_conn ||= create_json_rpc_connection(base)
950
-
951
- begin
952
- response = send_http_request(@rpc_conn, rpc_ep, request)
953
- record_activity
954
-
955
- unless response.success?
956
- raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
957
- end
958
-
959
- response
960
- rescue Faraday::ConnectionFailed => e
961
- raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
962
- end
963
- end
964
-
965
- # Create a Faraday connection for JSON-RPC
966
- # @param base_url [String] the base URL for the connection
967
- # @return [Faraday::Connection] the configured connection
968
- def create_json_rpc_connection(base_url)
969
- Faraday.new(url: base_url) do |f|
970
- f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
971
- f.options.open_timeout = @read_timeout
972
- f.options.timeout = @read_timeout
973
- f.adapter Faraday.default_adapter
974
- end
975
- end
976
-
977
- # Send an HTTP request with the proper headers and body
978
- # @param conn [Faraday::Connection] the connection to use
979
- # @param endpoint [String] the endpoint to post to
980
- # @param request [Hash] the request data
981
- # @return [Faraday::Response] the HTTP response
982
- def send_http_request(conn, endpoint, request)
983
- response = conn.post(endpoint) do |req|
984
- req.headers['Content-Type'] = 'application/json'
985
- req.headers['Accept'] = 'application/json'
986
- (@headers.dup.tap do |h|
987
- h.delete('Accept')
988
- h.delete('Cache-Control')
989
- end).each do |k, v|
990
- req.headers[k] = v
991
- end
992
- req.body = request.to_json
993
- end
994
-
995
- @logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
996
- response
997
- end
998
-
999
- # Wait for an SSE result to arrive
1000
- # @param request [Hash] the original JSON-RPC request
1001
- # @return [Hash] the result data
1002
- # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
1003
- def wait_for_sse_result(request)
1004
- request_id = request[:id]
1005
- start_time = Time.now
1006
- timeout = @read_timeout || 10
1007
-
1008
- ensure_sse_connection_active
1009
-
1010
- wait_for_result_with_timeout(request_id, start_time, timeout)
1011
- end
1012
-
1013
- # Ensure the SSE connection is active, reconnect if needed
1014
- def ensure_sse_connection_active
1015
- return if connection_active?
1016
-
1017
- @logger.warn('SSE connection is not active, reconnecting before waiting for result')
1018
- begin
1019
- cleanup
1020
- connect
1021
- rescue MCPClient::Errors::ConnectionError => e
1022
- raise MCPClient::Errors::ConnectionError, "Failed to reconnect SSE for result: #{e.message}"
1023
- end
1024
- end
1025
-
1026
- # Wait for a result with timeout
1027
- # @param request_id [Integer] the request ID to wait for
1028
- # @param start_time [Time] when the wait started
1029
- # @param timeout [Integer] the timeout in seconds
1030
- # @return [Hash] the result when available
1031
- # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
1032
- def wait_for_result_with_timeout(request_id, start_time, timeout)
1033
- loop do
1034
- result = check_for_result(request_id)
1035
- return result if result
1036
-
1037
- unless connection_active?
1038
- raise MCPClient::Errors::ConnectionError,
1039
- 'SSE connection lost while waiting for result'
1040
- end
1041
-
1042
- time_elapsed = Time.now - start_time
1043
- break if time_elapsed > timeout
1044
-
1045
- sleep 0.1
1046
- end
1047
-
1048
- raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
1049
- end
1050
-
1051
- # Check if a result is available for the given request ID
1052
- # @param request_id [Integer] the request ID to check
1053
- # @return [Hash, nil] the result if available, nil otherwise
1054
- def check_for_result(request_id)
1055
- result = nil
1056
- @mutex.synchronize do
1057
- result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
1058
- end
1059
-
1060
- if result
1061
- record_activity
1062
- return result
1063
- end
1064
-
1065
- nil
1066
- end
1067
-
1068
- # Parse a direct (non-SSE) JSON-RPC response
1069
- # @param response [Faraday::Response] the HTTP response
1070
- # @return [Hash] the parsed result
1071
- # @raise [MCPClient::Errors::TransportError] if parsing fails
1072
- def parse_direct_response(response)
1073
- data = JSON.parse(response.body)
1074
- data['result']
1075
- rescue JSON::ParserError => e
1076
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
1077
- end
1078
453
  end
1079
454
  end