ruby-mcp-client 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,34 @@ 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
 
25
+ # Default values for connection monitoring
26
+ DEFAULT_MAX_PING_FAILURES = 3
27
+ DEFAULT_MAX_RECONNECT_ATTEMPTS = 5
28
+
29
+ # Reconnection backoff constants
30
+ BASE_RECONNECT_DELAY = 0.5
31
+ MAX_RECONNECT_DELAY = 30
32
+ JITTER_FACTOR = 0.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
17
42
  attr_reader :base_url, :tools, :server_info, :capabilities
18
43
 
19
44
  # @param base_url [String] The base URL of the MCP server
@@ -22,10 +47,11 @@ module MCPClient
22
47
  # @param ping [Integer] Time in seconds after which to send ping if no activity (default: 10)
23
48
  # @param retries [Integer] number of retry attempts on transient errors
24
49
  # @param retry_backoff [Numeric] base delay in seconds for exponential backoff
50
+ # @param name [String, nil] optional name for this server
25
51
  # @param logger [Logger, nil] optional logger
26
52
  def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
27
- retries: 0, retry_backoff: 1, logger: nil)
28
- super()
53
+ retries: 0, retry_backoff: 1, name: nil, logger: nil)
54
+ super(name: name)
29
55
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
30
56
  @logger.progname = self.class.name
31
57
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
@@ -85,22 +111,20 @@ module MCPClient
85
111
  return @tools if @tools
86
112
  end
87
113
 
88
- ensure_initialized
89
-
90
114
  begin
115
+ ensure_initialized
116
+
91
117
  tools_data = request_tools_list
92
118
  @mutex.synchronize do
93
119
  @tools = tools_data.map do |tool_data|
94
- MCPClient::Tool.from_json(tool_data)
120
+ MCPClient::Tool.from_json(tool_data, server: self)
95
121
  end
96
122
  end
97
123
 
98
124
  @mutex.synchronize { @tools }
99
- rescue MCPClient::Errors::TransportError
100
- # Re-raise TransportError directly
125
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
126
+ # Re-raise these errors directly
101
127
  raise
102
- rescue JSON::ParserError => e
103
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
104
128
  rescue StandardError => e
105
129
  raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
106
130
  end
@@ -113,31 +137,18 @@ module MCPClient
113
137
  # @raise [MCPClient::Errors::ServerError] if server returns an error
114
138
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
115
139
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
140
+ # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
116
141
  def call_tool(tool_name, parameters)
117
- ensure_initialized
118
-
119
- begin
120
- request_id = @mutex.synchronize { @request_id += 1 }
121
-
122
- json_rpc_request = {
123
- jsonrpc: '2.0',
124
- id: request_id,
125
- method: 'tools/call',
126
- params: {
127
- name: tool_name,
128
- arguments: parameters
129
- }
130
- }
131
-
132
- send_jsonrpc_request(json_rpc_request)
133
- rescue MCPClient::Errors::TransportError
134
- # Re-raise TransportError directly
135
- raise
136
- rescue JSON::ParserError => e
137
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
138
- rescue StandardError => e
139
- raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
140
- 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}"
141
152
  end
142
153
 
143
154
  # Connect to the MCP server over HTTP/HTTPS with SSE
@@ -146,7 +157,13 @@ module MCPClient
146
157
  def connect
147
158
  return true if @mutex.synchronize { @connection_established }
148
159
 
160
+ # Check for pre-existing auth error (needed for tests)
161
+ pre_existing_auth_error = @mutex.synchronize { @auth_error }
162
+
149
163
  begin
164
+ # Don't reset auth error if it's pre-existing
165
+ @mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
166
+
150
167
  start_sse_thread
151
168
  effective_timeout = [@read_timeout || 30, 30].min
152
169
  wait_for_connection(timeout: effective_timeout)
@@ -154,16 +171,12 @@ module MCPClient
154
171
  true
155
172
  rescue MCPClient::Errors::ConnectionError => e
156
173
  cleanup
157
- # Check for stored auth error first, as it's more specific
158
- auth_error = @mutex.synchronize { @auth_error }
159
- raise MCPClient::Errors::ConnectionError, auth_error if auth_error
160
-
161
- raise MCPClient::Errors::ConnectionError, e.message if e.message.include?('Authorization failed')
162
-
163
- raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
174
+ # Simply pass through any ConnectionError without wrapping it again
175
+ # This prevents duplicate error messages in the stack
176
+ raise e
164
177
  rescue StandardError => e
165
178
  cleanup
166
- # Check for stored auth error
179
+ # Check for stored auth error first as it's more specific
167
180
  auth_error = @mutex.synchronize { @auth_error }
168
181
  raise MCPClient::Errors::ConnectionError, auth_error if auth_error
169
182
 
@@ -172,263 +185,171 @@ module MCPClient
172
185
  end
173
186
 
174
187
  # Clean up the server connection
175
- # Properly closes HTTP connections and clears cached tools
188
+ # Properly closes HTTP connections and clears cached state
189
+ #
190
+ # @note This method preserves ping failure and reconnection metrics between
191
+ # reconnection attempts, allowing the client to track failures across
192
+ # multiple connection attempts. This is essential for proper reconnection
193
+ # logic and exponential backoff.
176
194
  def cleanup
177
195
  @mutex.synchronize do
196
+ # Set flags first before killing threads to prevent race conditions
197
+ # where threads might check flags after they're set but before they're killed
198
+ @connection_established = false
199
+ @sse_connected = false
200
+ @initialized = false # Reset initialization state for reconnection
201
+
202
+ # Log cleanup for debugging
203
+ @logger.debug('Cleaning up SSE connection')
204
+
205
+ # Store threads locally to avoid race conditions
206
+ sse_thread = @sse_thread
207
+ activity_thread = @activity_timer_thread
208
+
209
+ # Clear thread references first
210
+ @sse_thread = nil
211
+ @activity_timer_thread = nil
212
+
213
+ # Kill threads outside the critical section
178
214
  begin
179
- @sse_thread&.kill
180
- rescue StandardError
181
- nil
215
+ sse_thread&.kill
216
+ rescue StandardError => e
217
+ @logger.debug("Error killing SSE thread: #{e.message}")
182
218
  end
183
- @sse_thread = nil
184
219
 
185
220
  begin
186
- @activity_timer_thread&.kill
187
- rescue StandardError
188
- nil
221
+ activity_thread&.kill
222
+ rescue StandardError => e
223
+ @logger.debug("Error killing activity thread: #{e.message}")
189
224
  end
190
- @activity_timer_thread = nil
191
225
 
192
226
  if @http_client
193
227
  @http_client.finish if @http_client.started?
194
228
  @http_client = nil
195
229
  end
196
230
 
231
+ # Close Faraday connections if they exist
232
+ @rpc_conn = nil
233
+ @sse_conn = nil
234
+
197
235
  @tools = nil
198
- @connection_established = false
199
- @sse_connected = false
200
236
  # Don't clear auth error as we need it for reporting the correct error
237
+ # Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
201
238
  end
202
239
  end
203
240
 
204
- # Generic JSON-RPC request: send method with params and return result
205
- # @param method [String] JSON-RPC method name
206
- # @param params [Hash] parameters for the request
207
- # @return [Object] result from JSON-RPC response
208
- def rpc_request(method, params = {})
209
- ensure_initialized
210
- with_retry do
211
- request_id = @mutex.synchronize { @request_id += 1 }
212
- request = { jsonrpc: '2.0', id: request_id, method: method, params: params }
213
- send_jsonrpc_request(request)
241
+ private
242
+
243
+ # Start the SSE thread to listen for events
244
+ # This thread handles the long-lived Server-Sent Events connection
245
+ # @return [Thread] the SSE thread
246
+ # @private
247
+ def start_sse_thread
248
+ return if @sse_thread&.alive?
249
+
250
+ @sse_thread = Thread.new do
251
+ handle_sse_connection
214
252
  end
215
253
  end
216
254
 
217
- # Send a JSON-RPC notification (no response expected)
218
- # @param method [String] JSON-RPC method name
219
- # @param params [Hash] parameters for the notification
255
+ # Handle the SSE connection in a separate method to reduce method size
220
256
  # @return [void]
221
- def rpc_notify(method, params = {})
222
- ensure_initialized
257
+ # @private
258
+ def handle_sse_connection
223
259
  uri = URI.parse(@base_url)
224
- base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
225
- rpc_ep = @mutex.synchronize { @rpc_endpoint }
226
- @rpc_conn ||= Faraday.new(url: base) do |f|
227
- f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
228
- f.options.open_timeout = @read_timeout
229
- f.options.timeout = @read_timeout
230
- f.adapter Faraday.default_adapter
231
- end
232
- response = @rpc_conn.post(rpc_ep) do |req|
233
- req.headers['Content-Type'] = 'application/json'
234
- req.headers['Accept'] = 'application/json'
235
- (@headers.dup.tap do |h|
236
- h.delete('Accept')
237
- h.delete('Cache-Control')
238
- end).each do |k, v|
239
- req.headers[k] = v
240
- end
241
- req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
242
- end
243
- unless response.success?
244
- raise MCPClient::Errors::ServerError, "Notification failed: #{response.status} #{response.reason_phrase}"
245
- end
246
- rescue StandardError => e
247
- raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
248
- end
260
+ sse_path = uri.request_uri
261
+ conn = setup_sse_connection(uri)
249
262
 
250
- # Ping the server to keep the connection alive
251
- # @return [Hash] the result of the ping request
252
- def ping
253
- rpc_request('ping')
254
- end
255
-
256
- private
263
+ reset_sse_connection_state
257
264
 
258
- # Start the activity monitor thread
259
- # This thread monitors connection activity and:
260
- # 1. Sends a ping if there's no activity for @ping_interval seconds
261
- # 2. Closes the connection if there's no activity for @close_after seconds
262
- def start_activity_monitor
263
- return if @activity_timer_thread&.alive?
264
-
265
- @mutex.synchronize { @last_activity_time = Time.now }
266
-
267
- @activity_timer_thread = Thread.new do
268
- loop do
269
- sleep 1 # Check every second
270
-
271
- last_activity = nil
272
- @mutex.synchronize { last_activity = @last_activity_time }
273
-
274
- time_since_activity = Time.now - last_activity
275
-
276
- if @close_after && time_since_activity >= @close_after
277
- @logger.info("Closing connection due to inactivity (#{time_since_activity.round(1)}s)")
278
- cleanup
279
- break
280
- elsif @ping_interval && time_since_activity >= @ping_interval
281
- begin
282
- @logger.debug("Sending ping after #{time_since_activity.round(1)}s of inactivity")
283
- ping
284
- @mutex.synchronize { @last_activity_time = Time.now }
285
- rescue StandardError => e
286
- @logger.error("Error sending ping: #{e.message}")
287
- end
288
- end
289
- end
265
+ begin
266
+ establish_sse_connection(conn, sse_path)
267
+ rescue MCPClient::Errors::ConnectionError => e
268
+ reset_connection_state
269
+ raise e
290
270
  rescue StandardError => e
291
- @logger.error("Activity monitor error: #{e.message}")
271
+ @logger.error("SSE connection error: #{e.message}")
272
+ reset_connection_state
273
+ ensure
274
+ @mutex.synchronize { @sse_connected = false }
292
275
  end
293
276
  end
294
277
 
295
- # Record activity to reset the inactivity timer
296
- def record_activity
297
- @mutex.synchronize { @last_activity_time = Time.now }
298
- end
299
-
300
- # Wait for SSE connection to be established with periodic checks
301
- # @param timeout [Integer] Maximum time to wait in seconds
302
- # @raise [MCPClient::Errors::ConnectionError] if timeout expires
303
- def wait_for_connection(timeout:)
278
+ # Reset SSE connection state
279
+ # @return [void]
280
+ # @private
281
+ def reset_sse_connection_state
304
282
  @mutex.synchronize do
305
- deadline = Time.now + timeout
306
-
307
- until @connection_established
308
- remaining = [1, deadline - Time.now].min
309
- break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
310
- end
311
-
312
- unless @connection_established
313
- cleanup
314
- raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
315
- end
283
+ @sse_connected = false
284
+ @connection_established = false
316
285
  end
317
286
  end
318
287
 
319
- # Ensure SSE initialization handshake has been performed
320
- def ensure_initialized
321
- return if @initialized
322
-
323
- connect
324
- perform_initialize
325
-
326
- @initialized = true
327
- end
328
-
329
- # Perform JSON-RPC initialize handshake with the MCP server
330
- def perform_initialize
331
- request_id = @mutex.synchronize { @request_id += 1 }
332
- json_rpc_request = {
333
- jsonrpc: '2.0',
334
- id: request_id,
335
- method: 'initialize',
336
- params: {
337
- 'protocolVersion' => MCPClient::VERSION,
338
- 'capabilities' => {},
339
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
340
- }
341
- }
342
- @logger.debug("Performing initialize RPC: #{json_rpc_request}")
343
- result = send_jsonrpc_request(json_rpc_request)
344
- return unless result.is_a?(Hash)
345
-
346
- @server_info = result['serverInfo'] if result.key?('serverInfo')
347
- @capabilities = result['capabilities'] if result.key?('capabilities')
348
- end
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
293
+ def establish_sse_connection(conn, sse_path)
294
+ conn.get(sse_path) do |req|
295
+ @headers.each { |k, v| req.headers[k] = v }
349
296
 
350
- # Set up the SSE connection
351
- # @param uri [URI] The parsed base URL
352
- # @return [Faraday::Connection] The configured Faraday connection
353
- def setup_sse_connection(uri)
354
- sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
355
-
356
- @sse_conn ||= Faraday.new(url: sse_base) do |f|
357
- f.options.open_timeout = 10
358
- f.options.timeout = nil
359
- f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
360
- f.adapter Faraday.default_adapter
297
+ req.options.on_data = proc do |chunk, _bytes|
298
+ process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
299
+ end
361
300
  end
362
-
363
- # Use response handling with status check
364
- @sse_conn.builder.use Faraday::Response::RaiseError
365
- @sse_conn
301
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
302
+ handle_sse_auth_response_error(e)
303
+ rescue Faraday::ConnectionFailed => e
304
+ handle_sse_connection_failed(e)
305
+ rescue Faraday::Error => e
306
+ handle_sse_general_error(e)
366
307
  end
367
308
 
368
- # Handle authorization errors from Faraday
369
- # @param error [Faraday::Error] The authorization error
370
- # @raise [MCPClient::Errors::ConnectionError] with appropriate message
371
- def handle_sse_auth_error(error)
372
- error_message = "Authorization failed: HTTP #{error.response[:status]}"
373
- @logger.error(error_message)
309
+ # Handle auth errors from SSE response
310
+ # @param err [Faraday::Error] the authorization error
311
+ # @return [void]
312
+ # @private
313
+ def handle_sse_auth_response_error(err)
314
+ error_status = err.response ? err.response[:status] : 'unknown'
315
+ auth_error = "Authorization failed: HTTP #{error_status}"
374
316
 
375
317
  @mutex.synchronize do
376
- @auth_error = error_message
318
+ @auth_error = auth_error
377
319
  @connection_established = false
378
320
  @connection_cv.broadcast
379
321
  end
380
- raise MCPClient::Errors::ConnectionError, error_message
322
+ @logger.error(auth_error)
381
323
  end
382
324
 
383
- # Reset connection state and signal waiting threads
384
- def reset_connection_state
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
330
+ def handle_sse_connection_failed(err)
331
+ @logger.error("Failed to connect to MCP server at #{@base_url}: #{err.message}")
332
+
385
333
  @mutex.synchronize do
386
334
  @connection_established = false
387
335
  @connection_cv.broadcast
388
336
  end
337
+ raise
389
338
  end
390
339
 
391
- # Start the SSE thread to listen for events
392
- def start_sse_thread
393
- return if @sse_thread&.alive?
394
-
395
- @sse_thread = Thread.new do
396
- uri = URI.parse(@base_url)
397
- sse_path = uri.request_uri
398
- conn = setup_sse_connection(uri)
399
-
400
- # Reset connection state
401
- @mutex.synchronize do
402
- @sse_connected = false
403
- @connection_established = false
404
- end
405
-
406
- begin
407
- conn.get(sse_path) do |req|
408
- @headers.each { |k, v| req.headers[k] = v }
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
345
+ def handle_sse_general_error(err)
346
+ @logger.error("Failed SSE connection: #{err.message}")
409
347
 
410
- req.options.on_data = proc do |chunk, _bytes|
411
- process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
412
- end
413
- end
414
- rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
415
- handle_sse_auth_error(e)
416
- rescue Faraday::Error => e
417
- @logger.error("Failed SSE connection: #{e.message}")
418
- raise
419
- end
420
- rescue MCPClient::Errors::ConnectionError => e
421
- # Re-raise connection errors to propagate them
422
- # Signal connect method to stop waiting
423
- reset_connection_state
424
- raise e
425
- rescue StandardError => e
426
- @logger.error("SSE connection error: #{e.message}")
427
- # Signal connect method to avoid deadlock
428
- reset_connection_state
429
- ensure
430
- @mutex.synchronize { @sse_connected = false }
348
+ @mutex.synchronize do
349
+ @connection_established = false
350
+ @connection_cv.broadcast
431
351
  end
352
+ raise
432
353
  end
433
354
 
434
355
  # Process an SSE chunk from the server
@@ -477,21 +398,11 @@ module MCPClient
477
398
  event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
478
399
  end
479
400
 
480
- # Handle SSE endpoint event
481
- # @param data [String] The endpoint path
482
- def handle_endpoint_event(data)
483
- @mutex.synchronize do
484
- @rpc_endpoint = data
485
- @sse_connected = true
486
- @connection_established = true
487
- @connection_cv.broadcast
488
- end
489
- end
490
-
491
401
  # Check if the error represents an authorization error
492
402
  # @param error_message [String] The error message from the server
493
403
  # @param error_code [Integer, nil] The error code if available
494
404
  # @return [Boolean] True if it's an authorization error
405
+ # @private
495
406
  def authorization_error?(error_message, error_code)
496
407
  return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
497
408
  return true if [401, -32_000].include?(error_code)
@@ -501,6 +412,9 @@ module MCPClient
501
412
 
502
413
  # Handle authorization error in SSE message
503
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
504
418
  def handle_sse_auth_error_message(error_message)
505
419
  @mutex.synchronize do
506
420
  @auth_error = "Authorization failed: #{error_message}"
@@ -511,144 +425,16 @@ module MCPClient
511
425
  raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
512
426
  end
513
427
 
514
- # Process error messages in SSE responses
515
- # @param data [Hash] The parsed SSE message data
516
- def process_error_in_message(data)
517
- return unless data['error']
518
-
519
- error_message = data['error']['message'] || 'Unknown server error'
520
- error_code = data['error']['code']
521
-
522
- # Handle unauthorized errors (close connection immediately)
523
- handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
524
-
525
- @logger.error("Server error: #{error_message}")
526
- true # Error was processed
527
- end
528
-
529
- # Process JSON-RPC notifications
530
- # @param data [Hash] The parsed SSE message data
531
- # @return [Boolean] True if a notification was processed
532
- def process_notification(data)
533
- return false unless data['method'] && !data.key?('id')
534
-
535
- @notification_callback&.call(data['method'], data['params'])
536
- true
537
- end
538
-
539
- # Process JSON-RPC responses
540
- # @param data [Hash] The parsed SSE message data
541
- # @return [Boolean] True if a response was processed
542
- def process_response(data)
543
- return false unless data['id']
544
-
545
- @mutex.synchronize do
546
- # Store tools data if present
547
- @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
548
-
549
- # Store response for the waiting request
550
- if data['error']
551
- @sse_results[data['id']] = {
552
- 'isError' => true,
553
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
554
- }
555
- elsif data['result']
556
- @sse_results[data['id']] = data['result']
557
- end
558
- end
559
-
560
- true
561
- end
562
-
563
- # Parse and handle an SSE event
564
- # @param event_data [String] the event data to parse
565
- def parse_and_handle_sse_event(event_data)
566
- event = parse_sse_event(event_data)
567
- return if event.nil?
568
-
569
- case event[:event]
570
- when 'endpoint'
571
- handle_endpoint_event(event[:data])
572
- when 'ping'
573
- # Received ping event, no action needed
574
- when 'message'
575
- handle_message_event(event)
576
- end
577
- end
578
-
579
- # Handle a message event from SSE
580
- # @param event [Hash] The parsed SSE event
581
- def handle_message_event(event)
582
- return if event[:data].empty?
583
-
584
- begin
585
- data = JSON.parse(event[:data])
586
-
587
- # Process the message in order of precedence
588
- return if process_error_in_message(data)
589
-
590
- return if process_notification(data)
591
-
592
- process_response(data)
593
- rescue MCPClient::Errors::ConnectionError
594
- # Re-raise connection errors to propagate to the calling code
595
- raise
596
- rescue JSON::ParserError => e
597
- @logger.warn("Failed to parse JSON from event data: #{e.message}")
598
- rescue StandardError => e
599
- @logger.error("Error processing SSE event: #{e.message}")
600
- end
601
- end
602
-
603
- # Parse an SSE event
604
- # @param event_data [String] the event data to parse
605
- # @return [Hash, nil] the parsed event, or nil if the event is invalid
606
- def parse_sse_event(event_data)
607
- event = { event: 'message', data: '', id: nil }
608
- data_lines = []
609
- has_content = false
610
-
611
- event_data.each_line do |line|
612
- line = line.chomp
613
- next if line.empty?
614
-
615
- # Skip SSE comments (lines starting with colon)
616
- next if line.start_with?(':')
617
-
618
- has_content = true
619
-
620
- if line.start_with?('event:')
621
- event[:event] = line[6..].strip
622
- elsif line.start_with?('data:')
623
- data_lines << line[5..].strip
624
- elsif line.start_with?('id:')
625
- event[:id] = line[3..].strip
626
- end
627
- end
628
-
629
- event[:data] = data_lines.join("\n")
630
-
631
- # Return the event even if data is empty as long as we had non-comment content
632
- has_content ? event : nil
633
- end
634
-
635
428
  # Request the tools list using JSON-RPC
636
429
  # @return [Array<Hash>] the tools data
430
+ # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
431
+ # @private
637
432
  def request_tools_list
638
433
  @mutex.synchronize do
639
434
  return @tools_data if @tools_data
640
435
  end
641
436
 
642
- request_id = @mutex.synchronize { @request_id += 1 }
643
-
644
- json_rpc_request = {
645
- jsonrpc: '2.0',
646
- id: request_id,
647
- method: 'tools/list',
648
- params: {}
649
- }
650
-
651
- result = send_jsonrpc_request(json_rpc_request)
437
+ result = rpc_request('tools/list')
652
438
 
653
439
  if result && result['tools']
654
440
  @mutex.synchronize do
@@ -664,106 +450,5 @@ module MCPClient
664
450
 
665
451
  raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
666
452
  end
667
-
668
- # Helper: execute block with retry/backoff for transient errors
669
- # @yield block to execute
670
- # @return result of block
671
- def with_retry
672
- attempts = 0
673
- begin
674
- yield
675
- rescue MCPClient::Errors::TransportError, MCPClient::Errors::ServerError, IOError, Errno::ETIMEDOUT,
676
- Errno::ECONNRESET => e
677
- attempts += 1
678
- if attempts <= @max_retries
679
- delay = @retry_backoff * (2**(attempts - 1))
680
- @logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
681
- sleep(delay)
682
- retry
683
- end
684
- raise
685
- end
686
- end
687
-
688
- # Send a JSON-RPC request to the server and wait for result
689
- # @param request [Hash] the JSON-RPC request
690
- # @return [Hash] the result of the request
691
- def send_jsonrpc_request(request)
692
- @logger.debug("Sending JSON-RPC request: #{request.to_json}")
693
-
694
- # Record activity when sending a request
695
- record_activity
696
-
697
- uri = URI.parse(@base_url)
698
- base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
699
- rpc_ep = @mutex.synchronize { @rpc_endpoint }
700
-
701
- @rpc_conn ||= Faraday.new(url: base) do |f|
702
- f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
703
- f.options.open_timeout = @read_timeout
704
- f.options.timeout = @read_timeout
705
- f.adapter Faraday.default_adapter
706
- end
707
-
708
- response = @rpc_conn.post(rpc_ep) do |req|
709
- req.headers['Content-Type'] = 'application/json'
710
- req.headers['Accept'] = 'application/json'
711
- (@headers.dup.tap do |h|
712
- h.delete('Accept')
713
- h.delete('Cache-Control')
714
- end).each do |k, v|
715
- req.headers[k] = v
716
- end
717
- req.body = request.to_json
718
- end
719
- @logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
720
-
721
- # Record activity when receiving a response
722
- record_activity
723
-
724
- unless response.success?
725
- raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
726
- end
727
-
728
- if @use_sse
729
- # Wait for result via SSE channel
730
- request_id = request[:id]
731
- start_time = Time.now
732
- # Use the specified read_timeout for the overall operation
733
- timeout = @read_timeout || 10
734
-
735
- # Check every 100ms for the result, with a total timeout from read_timeout
736
- loop do
737
- result = nil
738
- @mutex.synchronize do
739
- result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
740
- end
741
-
742
- if result
743
- # Record activity when receiving a result
744
- record_activity
745
- return result
746
- end
747
-
748
- current_time = Time.now
749
- time_elapsed = current_time - start_time
750
-
751
- # If we've exceeded the timeout, raise an error
752
- break if time_elapsed > timeout
753
-
754
- # Sleep for a short time before checking again
755
- sleep 0.1
756
- end
757
-
758
- raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
759
- else
760
- begin
761
- data = JSON.parse(response.body)
762
- data['result']
763
- rescue JSON::ParserError => e
764
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
765
- end
766
- end
767
- end
768
453
  end
769
454
  end