ruby-mcp-client 0.5.3 → 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 +41 -11
- data/lib/mcp_client/client.rb +125 -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 +6 -3
- data/lib/mcp_client/server_sse.rb +463 -153
- data/lib/mcp_client/server_stdio.rb +4 -3
- data/lib/mcp_client/tool.rb +22 -4
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +20 -9
- metadata +2 -2
@@ -14,6 +14,15 @@ module MCPClient
|
|
14
14
|
# Ratio of close_after timeout to ping interval
|
15
15
|
CLOSE_AFTER_PING_RATIO = 2.5
|
16
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
|
+
|
17
26
|
attr_reader :base_url, :tools, :server_info, :capabilities
|
18
27
|
|
19
28
|
# @param base_url [String] The base URL of the MCP server
|
@@ -22,10 +31,11 @@ module MCPClient
|
|
22
31
|
# @param ping [Integer] Time in seconds after which to send ping if no activity (default: 10)
|
23
32
|
# @param retries [Integer] number of retry attempts on transient errors
|
24
33
|
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
34
|
+
# @param name [String, nil] optional name for this server
|
25
35
|
# @param logger [Logger, nil] optional logger
|
26
36
|
def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
|
27
|
-
retries: 0, retry_backoff: 1, logger: nil)
|
28
|
-
super()
|
37
|
+
retries: 0, retry_backoff: 1, name: nil, logger: nil)
|
38
|
+
super(name: name)
|
29
39
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
30
40
|
@logger.progname = self.class.name
|
31
41
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
@@ -85,22 +95,20 @@ module MCPClient
|
|
85
95
|
return @tools if @tools
|
86
96
|
end
|
87
97
|
|
88
|
-
ensure_initialized
|
89
|
-
|
90
98
|
begin
|
99
|
+
ensure_initialized
|
100
|
+
|
91
101
|
tools_data = request_tools_list
|
92
102
|
@mutex.synchronize do
|
93
103
|
@tools = tools_data.map do |tool_data|
|
94
|
-
MCPClient::Tool.from_json(tool_data)
|
104
|
+
MCPClient::Tool.from_json(tool_data, server: self)
|
95
105
|
end
|
96
106
|
end
|
97
107
|
|
98
108
|
@mutex.synchronize { @tools }
|
99
|
-
rescue MCPClient::Errors::TransportError
|
100
|
-
# Re-raise
|
109
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
110
|
+
# Re-raise these errors directly
|
101
111
|
raise
|
102
|
-
rescue JSON::ParserError => e
|
103
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
104
112
|
rescue StandardError => e
|
105
113
|
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
106
114
|
end
|
@@ -113,29 +121,26 @@ module MCPClient
|
|
113
121
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
114
122
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
115
123
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
124
|
+
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
116
125
|
def call_tool(tool_name, parameters)
|
117
|
-
|
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
|
118
132
|
|
133
|
+
# Use rpc_request to handle the actual RPC call
|
119
134
|
begin
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
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
|
+
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
|
135
141
|
raise
|
136
|
-
rescue JSON::ParserError => e
|
137
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
138
142
|
rescue StandardError => e
|
143
|
+
# For all other errors, wrap in ToolCallError
|
139
144
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
140
145
|
end
|
141
146
|
end
|
@@ -146,7 +151,13 @@ module MCPClient
|
|
146
151
|
def connect
|
147
152
|
return true if @mutex.synchronize { @connection_established }
|
148
153
|
|
154
|
+
# Check for pre-existing auth error (needed for tests)
|
155
|
+
pre_existing_auth_error = @mutex.synchronize { @auth_error }
|
156
|
+
|
149
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
|
+
|
150
161
|
start_sse_thread
|
151
162
|
effective_timeout = [@read_timeout || 30, 30].min
|
152
163
|
wait_for_connection(timeout: effective_timeout)
|
@@ -154,16 +165,12 @@ module MCPClient
|
|
154
165
|
true
|
155
166
|
rescue MCPClient::Errors::ConnectionError => e
|
156
167
|
cleanup
|
157
|
-
#
|
158
|
-
|
159
|
-
raise
|
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}"
|
168
|
+
# Simply pass through any ConnectionError without wrapping it again
|
169
|
+
# This prevents duplicate error messages in the stack
|
170
|
+
raise e
|
164
171
|
rescue StandardError => e
|
165
172
|
cleanup
|
166
|
-
# Check for stored auth error
|
173
|
+
# Check for stored auth error first as it's more specific
|
167
174
|
auth_error = @mutex.synchronize { @auth_error }
|
168
175
|
raise MCPClient::Errors::ConnectionError, auth_error if auth_error
|
169
176
|
|
@@ -172,32 +179,56 @@ module MCPClient
|
|
172
179
|
end
|
173
180
|
|
174
181
|
# Clean up the server connection
|
175
|
-
# 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.
|
176
188
|
def cleanup
|
177
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
|
178
208
|
begin
|
179
|
-
|
180
|
-
rescue StandardError
|
181
|
-
|
209
|
+
sse_thread&.kill
|
210
|
+
rescue StandardError => e
|
211
|
+
@logger.debug("Error killing SSE thread: #{e.message}")
|
182
212
|
end
|
183
|
-
@sse_thread = nil
|
184
213
|
|
185
214
|
begin
|
186
|
-
|
187
|
-
rescue StandardError
|
188
|
-
|
215
|
+
activity_thread&.kill
|
216
|
+
rescue StandardError => e
|
217
|
+
@logger.debug("Error killing activity thread: #{e.message}")
|
189
218
|
end
|
190
|
-
@activity_timer_thread = nil
|
191
219
|
|
192
220
|
if @http_client
|
193
221
|
@http_client.finish if @http_client.started?
|
194
222
|
@http_client = nil
|
195
223
|
end
|
196
224
|
|
225
|
+
# Close Faraday connections if they exist
|
226
|
+
@rpc_conn = nil
|
227
|
+
@sse_conn = nil
|
228
|
+
|
197
229
|
@tools = nil
|
198
|
-
@connection_established = false
|
199
|
-
@sse_connected = false
|
200
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
|
201
232
|
end
|
202
233
|
end
|
203
234
|
|
@@ -205,6 +236,10 @@ module MCPClient
|
|
205
236
|
# @param method [String] JSON-RPC method name
|
206
237
|
# @param params [Hash] parameters for the request
|
207
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
|
208
243
|
def rpc_request(method, params = {})
|
209
244
|
ensure_initialized
|
210
245
|
with_retry do
|
@@ -249,46 +284,177 @@ module MCPClient
|
|
249
284
|
|
250
285
|
# Ping the server to keep the connection alive
|
251
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
|
252
290
|
def ping
|
253
291
|
rpc_request('ping')
|
254
292
|
end
|
255
293
|
|
256
294
|
private
|
257
295
|
|
258
|
-
# Start the activity monitor thread
|
259
|
-
#
|
260
|
-
#
|
261
|
-
#
|
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)
|
262
308
|
def start_activity_monitor
|
263
309
|
return if @activity_timer_thread&.alive?
|
264
310
|
|
265
|
-
@mutex.synchronize
|
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
|
266
318
|
|
267
319
|
@activity_timer_thread = Thread.new do
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
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
|
288
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
|
289
434
|
end
|
290
435
|
rescue StandardError => e
|
291
|
-
|
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}")
|
292
458
|
end
|
293
459
|
end
|
294
460
|
|
@@ -299,7 +465,7 @@ module MCPClient
|
|
299
465
|
|
300
466
|
# Wait for SSE connection to be established with periodic checks
|
301
467
|
# @param timeout [Integer] Maximum time to wait in seconds
|
302
|
-
# @raise [MCPClient::Errors::ConnectionError] if timeout expires
|
468
|
+
# @raise [MCPClient::Errors::ConnectionError] if timeout expires or auth error
|
303
469
|
def wait_for_connection(timeout:)
|
304
470
|
@mutex.synchronize do
|
305
471
|
deadline = Time.now + timeout
|
@@ -309,9 +475,15 @@ module MCPClient
|
|
309
475
|
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
310
476
|
end
|
311
477
|
|
478
|
+
# Check for auth error first
|
479
|
+
raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
|
480
|
+
|
312
481
|
unless @connection_established
|
313
482
|
cleanup
|
314
|
-
|
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
|
315
487
|
end
|
316
488
|
end
|
317
489
|
end
|
@@ -367,7 +539,8 @@ module MCPClient
|
|
367
539
|
|
368
540
|
# Handle authorization errors from Faraday
|
369
541
|
# @param error [Faraday::Error] The authorization error
|
370
|
-
#
|
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
|
371
544
|
def handle_sse_auth_error(error)
|
372
545
|
error_message = "Authorization failed: HTTP #{error.response[:status]}"
|
373
546
|
@logger.error(error_message)
|
@@ -377,7 +550,7 @@ module MCPClient
|
|
377
550
|
@connection_established = false
|
378
551
|
@connection_cv.broadcast
|
379
552
|
end
|
380
|
-
raise
|
553
|
+
# Don't raise here - the main thread will check @auth_error and raise appropriately
|
381
554
|
end
|
382
555
|
|
383
556
|
# Reset connection state and signal waiting threads
|
@@ -389,48 +562,96 @@ module MCPClient
|
|
389
562
|
end
|
390
563
|
|
391
564
|
# Start the SSE thread to listen for events
|
565
|
+
# This thread handles the long-lived Server-Sent Events connection
|
392
566
|
def start_sse_thread
|
393
567
|
return if @sse_thread&.alive?
|
394
568
|
|
395
569
|
@sse_thread = Thread.new do
|
396
|
-
|
397
|
-
|
398
|
-
|
570
|
+
handle_sse_connection
|
571
|
+
end
|
572
|
+
end
|
399
573
|
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
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)
|
405
579
|
|
406
|
-
|
407
|
-
conn.get(sse_path) do |req|
|
408
|
-
@headers.each { |k, v| req.headers[k] = v }
|
580
|
+
reset_sse_connection_state
|
409
581
|
|
410
|
-
|
411
|
-
|
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
|
582
|
+
begin
|
583
|
+
establish_sse_connection(conn, sse_path)
|
420
584
|
rescue MCPClient::Errors::ConnectionError => e
|
421
|
-
# Re-raise connection errors to propagate them
|
422
|
-
# Signal connect method to stop waiting
|
423
585
|
reset_connection_state
|
424
586
|
raise e
|
425
587
|
rescue StandardError => e
|
426
588
|
@logger.error("SSE connection error: #{e.message}")
|
427
|
-
# Signal connect method to avoid deadlock
|
428
589
|
reset_connection_state
|
429
590
|
ensure
|
430
591
|
@mutex.synchronize { @sse_connected = false }
|
431
592
|
end
|
432
593
|
end
|
433
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
|
+
|
434
655
|
# Process an SSE chunk from the server
|
435
656
|
# @param chunk [String] the chunk to process
|
436
657
|
def process_sse_chunk(chunk)
|
@@ -639,16 +860,7 @@ module MCPClient
|
|
639
860
|
return @tools_data if @tools_data
|
640
861
|
end
|
641
862
|
|
642
|
-
|
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)
|
863
|
+
result = rpc_request('tools/list')
|
652
864
|
|
653
865
|
if result && result['tools']
|
654
866
|
@mutex.synchronize do
|
@@ -672,8 +884,7 @@ module MCPClient
|
|
672
884
|
attempts = 0
|
673
885
|
begin
|
674
886
|
yield
|
675
|
-
rescue MCPClient::Errors::TransportError,
|
676
|
-
Errno::ECONNRESET => e
|
887
|
+
rescue MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT, Errno::ECONNRESET => e
|
677
888
|
attempts += 1
|
678
889
|
if attempts <= @max_retries
|
679
890
|
delay = @retry_backoff * (2**(attempts - 1))
|
@@ -688,24 +899,88 @@ module MCPClient
|
|
688
899
|
# Send a JSON-RPC request to the server and wait for result
|
689
900
|
# @param request [Hash] the JSON-RPC request
|
690
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
|
691
905
|
def send_jsonrpc_request(request)
|
692
906
|
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
693
|
-
|
694
|
-
# Record activity when sending a request
|
695
907
|
record_activity
|
696
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)
|
697
945
|
uri = URI.parse(@base_url)
|
698
946
|
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
699
947
|
rpc_ep = @mutex.synchronize { @rpc_endpoint }
|
700
948
|
|
701
|
-
@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|
|
702
970
|
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
703
971
|
f.options.open_timeout = @read_timeout
|
704
972
|
f.options.timeout = @read_timeout
|
705
973
|
f.adapter Faraday.default_adapter
|
706
974
|
end
|
975
|
+
end
|
707
976
|
|
708
|
-
|
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|
|
709
984
|
req.headers['Content-Type'] = 'application/json'
|
710
985
|
req.headers['Accept'] = 'application/json'
|
711
986
|
(@headers.dup.tap do |h|
|
@@ -716,54 +991,89 @@ module MCPClient
|
|
716
991
|
end
|
717
992
|
req.body = request.to_json
|
718
993
|
end
|
994
|
+
|
719
995
|
@logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
|
996
|
+
response
|
997
|
+
end
|
720
998
|
|
721
|
-
|
722
|
-
|
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
|
723
1007
|
|
724
|
-
|
725
|
-
raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
|
726
|
-
end
|
1008
|
+
ensure_sse_connection_active
|
727
1009
|
|
728
|
-
|
729
|
-
|
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
|
1010
|
+
wait_for_result_with_timeout(request_id, start_time, timeout)
|
1011
|
+
end
|
734
1012
|
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
@mutex.synchronize do
|
739
|
-
result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
|
740
|
-
end
|
1013
|
+
# Ensure the SSE connection is active, reconnect if needed
|
1014
|
+
def ensure_sse_connection_active
|
1015
|
+
return if connection_active?
|
741
1016
|
|
742
|
-
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
|
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
|
747
1025
|
|
748
|
-
|
749
|
-
|
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
|
750
1041
|
|
751
|
-
|
752
|
-
|
1042
|
+
time_elapsed = Time.now - start_time
|
1043
|
+
break if time_elapsed > timeout
|
753
1044
|
|
754
|
-
|
755
|
-
|
756
|
-
end
|
1045
|
+
sleep 0.1
|
1046
|
+
end
|
757
1047
|
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
762
|
-
|
763
|
-
|
764
|
-
|
765
|
-
|
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
|
766
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}"
|
767
1077
|
end
|
768
1078
|
end
|
769
1079
|
end
|