ruby-mcp-client 0.5.2 → 0.6.0

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,16 +11,31 @@ 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
+ # Ratio of close_after timeout to ping interval
15
+ CLOSE_AFTER_PING_RATIO = 2.5
16
+
17
+ # Default values for connection monitoring
18
+ DEFAULT_MAX_PING_FAILURES = 3
19
+ DEFAULT_MAX_RECONNECT_ATTEMPTS = 5
20
+
21
+ # Reconnection backoff constants
22
+ BASE_RECONNECT_DELAY = 0.5
23
+ MAX_RECONNECT_DELAY = 30
24
+ JITTER_FACTOR = 0.25
25
+
14
26
  attr_reader :base_url, :tools, :server_info, :capabilities
15
27
 
16
28
  # @param base_url [String] The base URL of the MCP server
17
29
  # @param headers [Hash] Additional headers to include in requests
18
30
  # @param read_timeout [Integer] Read timeout in seconds (default: 30)
31
+ # @param ping [Integer] Time in seconds after which to send ping if no activity (default: 10)
19
32
  # @param retries [Integer] number of retry attempts on transient errors
20
33
  # @param retry_backoff [Numeric] base delay in seconds for exponential backoff
34
+ # @param name [String, nil] optional name for this server
21
35
  # @param logger [Logger, nil] optional logger
22
- def initialize(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1, logger: nil)
23
- super()
36
+ def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
37
+ retries: 0, retry_backoff: 1, name: nil, logger: nil)
38
+ super(name: name)
24
39
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
25
40
  @logger.progname = self.class.name
26
41
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
@@ -36,6 +51,9 @@ module MCPClient
36
51
  # HTTP client is managed via Faraday
37
52
  @tools = nil
38
53
  @read_timeout = read_timeout
54
+ @ping_interval = ping
55
+ # Set close_after to a multiple of the ping interval
56
+ @close_after = (ping * CLOSE_AFTER_PING_RATIO).to_i
39
57
 
40
58
  # SSE-provided JSON-RPC endpoint path for POST requests
41
59
  @rpc_endpoint = nil
@@ -51,6 +69,10 @@ module MCPClient
51
69
  @auth_error = nil
52
70
  # Whether to use SSE transport; may disable if handshake fails
53
71
  @use_sse = true
72
+
73
+ # Time of last activity
74
+ @last_activity_time = Time.now
75
+ @activity_timer_thread = nil
54
76
  end
55
77
 
56
78
  # Stream tool call fallback for SSE transport (yields single result)
@@ -73,22 +95,20 @@ module MCPClient
73
95
  return @tools if @tools
74
96
  end
75
97
 
76
- ensure_initialized
77
-
78
98
  begin
99
+ ensure_initialized
100
+
79
101
  tools_data = request_tools_list
80
102
  @mutex.synchronize do
81
103
  @tools = tools_data.map do |tool_data|
82
- MCPClient::Tool.from_json(tool_data)
104
+ MCPClient::Tool.from_json(tool_data, server: self)
83
105
  end
84
106
  end
85
107
 
86
108
  @mutex.synchronize { @tools }
87
- rescue MCPClient::Errors::TransportError
88
- # Re-raise TransportError directly
109
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
110
+ # Re-raise these errors directly
89
111
  raise
90
- rescue JSON::ParserError => e
91
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
92
112
  rescue StandardError => e
93
113
  raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
94
114
  end
@@ -101,29 +121,26 @@ module MCPClient
101
121
  # @raise [MCPClient::Errors::ServerError] if server returns an error
102
122
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
103
123
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
124
+ # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
104
125
  def call_tool(tool_name, parameters)
105
- ensure_initialized
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
106
132
 
133
+ # Use rpc_request to handle the actual RPC call
107
134
  begin
108
- request_id = @mutex.synchronize { @request_id += 1 }
109
-
110
- json_rpc_request = {
111
- jsonrpc: '2.0',
112
- id: request_id,
113
- method: 'tools/call',
114
- params: {
115
- name: tool_name,
116
- arguments: parameters
117
- }
118
- }
119
-
120
- send_jsonrpc_request(json_rpc_request)
121
- rescue MCPClient::Errors::TransportError
122
- # Re-raise TransportError directly
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
123
141
  raise
124
- rescue JSON::ParserError => e
125
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
126
142
  rescue StandardError => e
143
+ # For all other errors, wrap in ToolCallError
127
144
  raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
128
145
  end
129
146
  end
@@ -134,23 +151,26 @@ module MCPClient
134
151
  def connect
135
152
  return true if @mutex.synchronize { @connection_established }
136
153
 
154
+ # Check for pre-existing auth error (needed for tests)
155
+ pre_existing_auth_error = @mutex.synchronize { @auth_error }
156
+
137
157
  begin
158
+ # Don't reset auth error if it's pre-existing
159
+ @mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
160
+
138
161
  start_sse_thread
139
162
  effective_timeout = [@read_timeout || 30, 30].min
140
163
  wait_for_connection(timeout: effective_timeout)
164
+ start_activity_monitor
141
165
  true
142
166
  rescue MCPClient::Errors::ConnectionError => e
143
167
  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}"
168
+ # Simply pass through any ConnectionError without wrapping it again
169
+ # This prevents duplicate error messages in the stack
170
+ raise e
151
171
  rescue StandardError => e
152
172
  cleanup
153
- # Check for stored auth error
173
+ # Check for stored auth error first as it's more specific
154
174
  auth_error = @mutex.synchronize { @auth_error }
155
175
  raise MCPClient::Errors::ConnectionError, auth_error if auth_error
156
176
 
@@ -159,25 +179,56 @@ module MCPClient
159
179
  end
160
180
 
161
181
  # Clean up the server connection
162
- # Properly closes HTTP connections and clears cached tools
182
+ # Properly closes HTTP connections and clears cached state
183
+ #
184
+ # @note This method preserves ping failure and reconnection metrics between
185
+ # reconnection attempts, allowing the client to track failures across
186
+ # multiple connection attempts. This is essential for proper reconnection
187
+ # logic and exponential backoff.
163
188
  def cleanup
164
189
  @mutex.synchronize do
190
+ # Set flags first before killing threads to prevent race conditions
191
+ # where threads might check flags after they're set but before they're killed
192
+ @connection_established = false
193
+ @sse_connected = false
194
+ @initialized = false # Reset initialization state for reconnection
195
+
196
+ # Log cleanup for debugging
197
+ @logger.debug('Cleaning up SSE connection')
198
+
199
+ # Store threads locally to avoid race conditions
200
+ sse_thread = @sse_thread
201
+ activity_thread = @activity_timer_thread
202
+
203
+ # Clear thread references first
204
+ @sse_thread = nil
205
+ @activity_timer_thread = nil
206
+
207
+ # Kill threads outside the critical section
165
208
  begin
166
- @sse_thread&.kill
167
- rescue StandardError
168
- nil
209
+ sse_thread&.kill
210
+ rescue StandardError => e
211
+ @logger.debug("Error killing SSE thread: #{e.message}")
212
+ end
213
+
214
+ begin
215
+ activity_thread&.kill
216
+ rescue StandardError => e
217
+ @logger.debug("Error killing activity thread: #{e.message}")
169
218
  end
170
- @sse_thread = nil
171
219
 
172
220
  if @http_client
173
221
  @http_client.finish if @http_client.started?
174
222
  @http_client = nil
175
223
  end
176
224
 
225
+ # Close Faraday connections if they exist
226
+ @rpc_conn = nil
227
+ @sse_conn = nil
228
+
177
229
  @tools = nil
178
- @connection_established = false
179
- @sse_connected = false
180
230
  # Don't clear auth error as we need it for reporting the correct error
231
+ # Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
181
232
  end
182
233
  end
183
234
 
@@ -185,6 +236,10 @@ module MCPClient
185
236
  # @param method [String] JSON-RPC method name
186
237
  # @param params [Hash] parameters for the request
187
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
188
243
  def rpc_request(method, params = {})
189
244
  ensure_initialized
190
245
  with_retry do
@@ -227,11 +282,190 @@ module MCPClient
227
282
  raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
228
283
  end
229
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
+
230
294
  private
231
295
 
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
+
232
466
  # Wait for SSE connection to be established with periodic checks
233
467
  # @param timeout [Integer] Maximum time to wait in seconds
234
- # @raise [MCPClient::Errors::ConnectionError] if timeout expires
468
+ # @raise [MCPClient::Errors::ConnectionError] if timeout expires or auth error
235
469
  def wait_for_connection(timeout:)
236
470
  @mutex.synchronize do
237
471
  deadline = Time.now + timeout
@@ -241,9 +475,15 @@ module MCPClient
241
475
  break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
242
476
  end
243
477
 
478
+ # Check for auth error first
479
+ raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
480
+
244
481
  unless @connection_established
245
482
  cleanup
246
- raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
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
247
487
  end
248
488
  end
249
489
  end
@@ -299,7 +539,8 @@ module MCPClient
299
539
 
300
540
  # Handle authorization errors from Faraday
301
541
  # @param error [Faraday::Error] The authorization error
302
- # @raise [MCPClient::Errors::ConnectionError] with appropriate message
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
303
544
  def handle_sse_auth_error(error)
304
545
  error_message = "Authorization failed: HTTP #{error.response[:status]}"
305
546
  @logger.error(error_message)
@@ -309,7 +550,7 @@ module MCPClient
309
550
  @connection_established = false
310
551
  @connection_cv.broadcast
311
552
  end
312
- raise MCPClient::Errors::ConnectionError, error_message
553
+ # Don't raise here - the main thread will check @auth_error and raise appropriately
313
554
  end
314
555
 
315
556
  # Reset connection state and signal waiting threads
@@ -321,53 +562,104 @@ module MCPClient
321
562
  end
322
563
 
323
564
  # Start the SSE thread to listen for events
565
+ # This thread handles the long-lived Server-Sent Events connection
324
566
  def start_sse_thread
325
567
  return if @sse_thread&.alive?
326
568
 
327
569
  @sse_thread = Thread.new do
328
- uri = URI.parse(@base_url)
329
- sse_path = uri.request_uri
330
- conn = setup_sse_connection(uri)
570
+ handle_sse_connection
571
+ end
572
+ end
331
573
 
332
- # Reset connection state
333
- @mutex.synchronize do
334
- @sse_connected = false
335
- @connection_established = false
336
- end
574
+ # Handle the SSE connection in a separate method to reduce method size
575
+ def handle_sse_connection
576
+ uri = URI.parse(@base_url)
577
+ sse_path = uri.request_uri
578
+ conn = setup_sse_connection(uri)
337
579
 
338
- begin
339
- conn.get(sse_path) do |req|
340
- @headers.each { |k, v| req.headers[k] = v }
580
+ reset_sse_connection_state
341
581
 
342
- req.options.on_data = proc do |chunk, _bytes|
343
- process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
344
- end
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
351
- end
582
+ begin
583
+ establish_sse_connection(conn, sse_path)
352
584
  rescue MCPClient::Errors::ConnectionError => e
353
- # Re-raise connection errors to propagate them
354
- # Signal connect method to stop waiting
355
585
  reset_connection_state
356
586
  raise e
357
587
  rescue StandardError => e
358
588
  @logger.error("SSE connection error: #{e.message}")
359
- # Signal connect method to avoid deadlock
360
589
  reset_connection_state
361
590
  ensure
362
591
  @mutex.synchronize { @sse_connected = false }
363
592
  end
364
593
  end
365
594
 
595
+ # Reset SSE connection state
596
+ def reset_sse_connection_state
597
+ @mutex.synchronize do
598
+ @sse_connected = false
599
+ @connection_established = false
600
+ end
601
+ end
602
+
603
+ # Establish SSE connection with error handling
604
+ def establish_sse_connection(conn, sse_path)
605
+ conn.get(sse_path) do |req|
606
+ @headers.each { |k, v| req.headers[k] = v }
607
+
608
+ req.options.on_data = proc do |chunk, _bytes|
609
+ process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
610
+ end
611
+ end
612
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
613
+ handle_sse_auth_response_error(e)
614
+ rescue Faraday::ConnectionFailed => e
615
+ handle_sse_connection_failed(e)
616
+ rescue Faraday::Error => e
617
+ handle_sse_general_error(e)
618
+ end
619
+
620
+ # Handle auth errors from SSE response
621
+ def handle_sse_auth_response_error(err)
622
+ error_status = err.response ? err.response[:status] : 'unknown'
623
+ auth_error = "Authorization failed: HTTP #{error_status}"
624
+
625
+ @mutex.synchronize do
626
+ @auth_error = auth_error
627
+ @connection_established = false
628
+ @connection_cv.broadcast
629
+ end
630
+ @logger.error(auth_error)
631
+ end
632
+
633
+ # Handle connection failures in SSE
634
+ def handle_sse_connection_failed(err)
635
+ @logger.error("Failed to connect to MCP server at #{@base_url}: #{err.message}")
636
+
637
+ @mutex.synchronize do
638
+ @connection_established = false
639
+ @connection_cv.broadcast
640
+ end
641
+ raise
642
+ end
643
+
644
+ # Handle general Faraday errors in SSE
645
+ def handle_sse_general_error(err)
646
+ @logger.error("Failed SSE connection: #{err.message}")
647
+
648
+ @mutex.synchronize do
649
+ @connection_established = false
650
+ @connection_cv.broadcast
651
+ end
652
+ raise
653
+ end
654
+
366
655
  # Process an SSE chunk from the server
367
656
  # @param chunk [String] the chunk to process
368
657
  def process_sse_chunk(chunk)
369
658
  @logger.debug("Processing SSE chunk: #{chunk.inspect}")
370
659
 
660
+ # Only record activity for real events
661
+ record_activity if chunk.include?('event:')
662
+
371
663
  # Check for direct JSON error responses (which aren't proper SSE events)
372
664
  if chunk.start_with?('{') && chunk.include?('"error"') &&
373
665
  (chunk.include?('Unauthorized') || chunk.include?('authentication'))
@@ -568,16 +860,7 @@ module MCPClient
568
860
  return @tools_data if @tools_data
569
861
  end
570
862
 
571
- request_id = @mutex.synchronize { @request_id += 1 }
572
-
573
- json_rpc_request = {
574
- jsonrpc: '2.0',
575
- id: request_id,
576
- method: 'tools/list',
577
- params: {}
578
- }
579
-
580
- result = send_jsonrpc_request(json_rpc_request)
863
+ result = rpc_request('tools/list')
581
864
 
582
865
  if result && result['tools']
583
866
  @mutex.synchronize do
@@ -601,8 +884,7 @@ module MCPClient
601
884
  attempts = 0
602
885
  begin
603
886
  yield
604
- rescue MCPClient::Errors::TransportError, MCPClient::Errors::ServerError, IOError, Errno::ETIMEDOUT,
605
- Errno::ECONNRESET => e
887
+ rescue MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT, Errno::ECONNRESET => e
606
888
  attempts += 1
607
889
  if attempts <= @max_retries
608
890
  delay = @retry_backoff * (2**(attempts - 1))
@@ -617,20 +899,88 @@ module MCPClient
617
899
  # Send a JSON-RPC request to the server and wait for result
618
900
  # @param request [Hash] the JSON-RPC request
619
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
620
905
  def send_jsonrpc_request(request)
621
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)
622
945
  uri = URI.parse(@base_url)
623
946
  base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
624
947
  rpc_ep = @mutex.synchronize { @rpc_endpoint }
625
948
 
626
- @rpc_conn ||= Faraday.new(url: base) do |f|
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|
627
970
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
628
971
  f.options.open_timeout = @read_timeout
629
972
  f.options.timeout = @read_timeout
630
973
  f.adapter Faraday.default_adapter
631
974
  end
975
+ end
632
976
 
633
- response = @rpc_conn.post(rpc_ep) do |req|
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|
634
984
  req.headers['Content-Type'] = 'application/json'
635
985
  req.headers['Accept'] = 'application/json'
636
986
  (@headers.dup.tap do |h|
@@ -641,36 +991,89 @@ module MCPClient
641
991
  end
642
992
  req.body = request.to_json
643
993
  end
994
+
644
995
  @logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
996
+ response
997
+ end
645
998
 
646
- unless response.success?
647
- raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
648
- end
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
649
1007
 
650
- if @use_sse
651
- # Wait for result via SSE channel
652
- request_id = request[:id]
653
- start_time = Time.now
654
- timeout = @read_timeout || 10
655
- loop do
656
- result = nil
657
- @mutex.synchronize do
658
- result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
659
- end
660
- return result if result
661
- break if Time.now - start_time > timeout
1008
+ ensure_sse_connection_active
662
1009
 
663
- sleep 0.1
664
- end
665
- raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
666
- else
667
- begin
668
- data = JSON.parse(response.body)
669
- data['result']
670
- rescue JSON::ParserError => e
671
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
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'
672
1040
  end
1041
+
1042
+ time_elapsed = Time.now - start_time
1043
+ break if time_elapsed > timeout
1044
+
1045
+ sleep 0.1
673
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}"
674
1077
  end
675
1078
  end
676
1079
  end