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.
- checksums.yaml +4 -4
- data/lib/mcp_client/json_rpc_common.rb +84 -0
- data/lib/mcp_client/server_factory.rb +52 -18
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +246 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +227 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +131 -0
- data/lib/mcp_client/server_sse.rb +53 -678
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +122 -0
- data/lib/mcp_client/server_stdio.rb +32 -126
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +10 -4
- metadata +7 -2
@@ -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
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|