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.
- checksums.yaml +4 -4
- data/README.md +55 -12
- data/lib/mcp_client/client.rb +131 -26
- data/lib/mcp_client/config_parser.rb +5 -1
- data/lib/mcp_client/errors.rb +4 -0
- data/lib/mcp_client/server_base.rb +20 -0
- data/lib/mcp_client/server_factory.rb +7 -3
- data/lib/mcp_client/server_sse.rb +511 -108
- data/lib/mcp_client/server_stdio.rb +4 -3
- data/lib/mcp_client/tool.rb +50 -4
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +22 -9
- metadata +2 -2
@@ -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,
|
23
|
-
|
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
|
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
|
-
|
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
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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
|
-
#
|
145
|
-
|
146
|
-
raise
|
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
|
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
|
-
|
167
|
-
rescue StandardError
|
168
|
-
|
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
|
-
|
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
|
-
#
|
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
|
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
|
-
|
329
|
-
|
330
|
-
|
570
|
+
handle_sse_connection
|
571
|
+
end
|
572
|
+
end
|
331
573
|
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
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
|
-
|
339
|
-
conn.get(sse_path) do |req|
|
340
|
-
@headers.each { |k, v| req.headers[k] = v }
|
580
|
+
reset_sse_connection_state
|
341
581
|
|
342
|
-
|
343
|
-
|
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
|
-
|
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,
|
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 ||=
|
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
|
-
|
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
|
-
|
647
|
-
|
648
|
-
|
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
|
-
|
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
|
-
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
669
|
-
|
670
|
-
|
671
|
-
|
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
|