ruby-mcp-client 0.5.1 → 0.5.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/README.md +1 -1
- data/lib/mcp_client/server_sse.rb +255 -67
- data/lib/mcp_client/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fbd3caf1e80f6c2caf625495072211b827ae08e3542fa33306537918d0b6eb5f
|
4
|
+
data.tar.gz: 1eea548ba10e7de3fec68a92d449e4552fde3c5920af045e2e828925dba2aad7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8eae783bd1e6a4db013a934e0686641befaec435fb288531ce85899efc0a66023d3d174dedffae21f75089f406f6368f286f13644701fbdee747492b795535b
|
7
|
+
data.tar.gz: 534cc5c88f8f3737c5fc3e684174980af74ac18398532e4b5698490b451b5fc5ff0831ca48639119445c71f4afa94ba3fca94ff81edfa03d1e3ba34a73c4f1b1
|
data/README.md
CHANGED
@@ -298,7 +298,7 @@ You can define MCP server configurations in JSON files for easier management:
|
|
298
298
|
}
|
299
299
|
```
|
300
300
|
|
301
|
-
A simpler example used in the Playwright demo (found in `examples/
|
301
|
+
A simpler example used in the Playwright demo (found in `examples/sample_server_definition.json`):
|
302
302
|
|
303
303
|
```json
|
304
304
|
{
|
@@ -48,6 +48,7 @@ module MCPClient
|
|
48
48
|
@connection_established = false
|
49
49
|
@connection_cv = @mutex.new_cond
|
50
50
|
@initialized = false
|
51
|
+
@auth_error = nil
|
51
52
|
# Whether to use SSE transport; may disable if handshake fails
|
52
53
|
@use_sse = true
|
53
54
|
end
|
@@ -131,25 +132,30 @@ module MCPClient
|
|
131
132
|
# @return [Boolean] true if connection was successful
|
132
133
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
133
134
|
def connect
|
134
|
-
@mutex.synchronize
|
135
|
-
return true if @connection_established
|
135
|
+
return true if @mutex.synchronize { @connection_established }
|
136
136
|
|
137
|
-
|
137
|
+
begin
|
138
138
|
start_sse_thread
|
139
|
+
effective_timeout = [@read_timeout || 30, 30].min
|
140
|
+
wait_for_connection(timeout: effective_timeout)
|
141
|
+
true
|
142
|
+
rescue MCPClient::Errors::ConnectionError => e
|
143
|
+
cleanup
|
144
|
+
# Check for stored auth error first, as it's more specific
|
145
|
+
auth_error = @mutex.synchronize { @auth_error }
|
146
|
+
raise MCPClient::Errors::ConnectionError, auth_error if auth_error
|
147
|
+
|
148
|
+
raise MCPClient::Errors::ConnectionError, e.message if e.message.include?('Authorization failed')
|
149
|
+
|
150
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
151
|
+
rescue StandardError => e
|
152
|
+
cleanup
|
153
|
+
# Check for stored auth error
|
154
|
+
auth_error = @mutex.synchronize { @auth_error }
|
155
|
+
raise MCPClient::Errors::ConnectionError, auth_error if auth_error
|
139
156
|
|
140
|
-
|
141
|
-
success = @connection_cv.wait(timeout) { @connection_established }
|
142
|
-
|
143
|
-
unless success
|
144
|
-
cleanup
|
145
|
-
raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
|
146
|
-
end
|
147
|
-
|
148
|
-
@connection_established
|
157
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
149
158
|
end
|
150
|
-
rescue StandardError => e
|
151
|
-
cleanup
|
152
|
-
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
153
159
|
end
|
154
160
|
|
155
161
|
# Clean up the server connection
|
@@ -171,6 +177,7 @@ module MCPClient
|
|
171
177
|
@tools = nil
|
172
178
|
@connection_established = false
|
173
179
|
@sse_connected = false
|
180
|
+
# Don't clear auth error as we need it for reporting the correct error
|
174
181
|
end
|
175
182
|
end
|
176
183
|
|
@@ -222,6 +229,25 @@ module MCPClient
|
|
222
229
|
|
223
230
|
private
|
224
231
|
|
232
|
+
# Wait for SSE connection to be established with periodic checks
|
233
|
+
# @param timeout [Integer] Maximum time to wait in seconds
|
234
|
+
# @raise [MCPClient::Errors::ConnectionError] if timeout expires
|
235
|
+
def wait_for_connection(timeout:)
|
236
|
+
@mutex.synchronize do
|
237
|
+
deadline = Time.now + timeout
|
238
|
+
|
239
|
+
until @connection_established
|
240
|
+
remaining = [1, deadline - Time.now].min
|
241
|
+
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
242
|
+
end
|
243
|
+
|
244
|
+
unless @connection_established
|
245
|
+
cleanup
|
246
|
+
raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
225
251
|
# Ensure SSE initialization handshake has been performed
|
226
252
|
def ensure_initialized
|
227
253
|
return if @initialized
|
@@ -253,34 +279,85 @@ module MCPClient
|
|
253
279
|
@capabilities = result['capabilities'] if result.key?('capabilities')
|
254
280
|
end
|
255
281
|
|
282
|
+
# Set up the SSE connection
|
283
|
+
# @param uri [URI] The parsed base URL
|
284
|
+
# @return [Faraday::Connection] The configured Faraday connection
|
285
|
+
def setup_sse_connection(uri)
|
286
|
+
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
287
|
+
|
288
|
+
@sse_conn ||= Faraday.new(url: sse_base) do |f|
|
289
|
+
f.options.open_timeout = 10
|
290
|
+
f.options.timeout = nil
|
291
|
+
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
292
|
+
f.adapter Faraday.default_adapter
|
293
|
+
end
|
294
|
+
|
295
|
+
# Use response handling with status check
|
296
|
+
@sse_conn.builder.use Faraday::Response::RaiseError
|
297
|
+
@sse_conn
|
298
|
+
end
|
299
|
+
|
300
|
+
# Handle authorization errors from Faraday
|
301
|
+
# @param error [Faraday::Error] The authorization error
|
302
|
+
# @raise [MCPClient::Errors::ConnectionError] with appropriate message
|
303
|
+
def handle_sse_auth_error(error)
|
304
|
+
error_message = "Authorization failed: HTTP #{error.response[:status]}"
|
305
|
+
@logger.error(error_message)
|
306
|
+
|
307
|
+
@mutex.synchronize do
|
308
|
+
@auth_error = error_message
|
309
|
+
@connection_established = false
|
310
|
+
@connection_cv.broadcast
|
311
|
+
end
|
312
|
+
raise MCPClient::Errors::ConnectionError, error_message
|
313
|
+
end
|
314
|
+
|
315
|
+
# Reset connection state and signal waiting threads
|
316
|
+
def reset_connection_state
|
317
|
+
@mutex.synchronize do
|
318
|
+
@connection_established = false
|
319
|
+
@connection_cv.broadcast
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
256
323
|
# Start the SSE thread to listen for events
|
257
324
|
def start_sse_thread
|
258
325
|
return if @sse_thread&.alive?
|
259
326
|
|
260
327
|
@sse_thread = Thread.new do
|
261
328
|
uri = URI.parse(@base_url)
|
262
|
-
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
263
329
|
sse_path = uri.request_uri
|
330
|
+
conn = setup_sse_connection(uri)
|
264
331
|
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
332
|
+
# Reset connection state
|
333
|
+
@mutex.synchronize do
|
334
|
+
@sse_connected = false
|
335
|
+
@connection_established = false
|
269
336
|
end
|
270
337
|
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
338
|
+
begin
|
339
|
+
conn.get(sse_path) do |req|
|
340
|
+
@headers.each { |k, v| req.headers[k] = v }
|
341
|
+
|
342
|
+
req.options.on_data = proc do |chunk, _bytes|
|
343
|
+
process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
|
344
|
+
end
|
276
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
|
277
351
|
end
|
278
|
-
rescue
|
279
|
-
#
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
352
|
+
rescue MCPClient::Errors::ConnectionError => e
|
353
|
+
# Re-raise connection errors to propagate them
|
354
|
+
# Signal connect method to stop waiting
|
355
|
+
reset_connection_state
|
356
|
+
raise e
|
357
|
+
rescue StandardError => e
|
358
|
+
@logger.error("SSE connection error: #{e.message}")
|
359
|
+
# Signal connect method to avoid deadlock
|
360
|
+
reset_connection_state
|
284
361
|
ensure
|
285
362
|
@mutex.synchronize { @sse_connected = false }
|
286
363
|
end
|
@@ -290,18 +367,126 @@ module MCPClient
|
|
290
367
|
# @param chunk [String] the chunk to process
|
291
368
|
def process_sse_chunk(chunk)
|
292
369
|
@logger.debug("Processing SSE chunk: #{chunk.inspect}")
|
293
|
-
local_buffer = nil
|
294
370
|
|
371
|
+
# Check for direct JSON error responses (which aren't proper SSE events)
|
372
|
+
if chunk.start_with?('{') && chunk.include?('"error"') &&
|
373
|
+
(chunk.include?('Unauthorized') || chunk.include?('authentication'))
|
374
|
+
begin
|
375
|
+
data = JSON.parse(chunk)
|
376
|
+
if data['error']
|
377
|
+
error_message = data['error']['message'] || 'Unknown server error'
|
378
|
+
|
379
|
+
@mutex.synchronize do
|
380
|
+
@auth_error = "Authorization failed: #{error_message}"
|
381
|
+
|
382
|
+
@connection_established = false
|
383
|
+
@connection_cv.broadcast
|
384
|
+
end
|
385
|
+
|
386
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
|
387
|
+
end
|
388
|
+
rescue JSON::ParserError
|
389
|
+
# Not valid JSON, process normally
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
event_buffers = nil
|
295
394
|
@mutex.synchronize do
|
296
395
|
@buffer += chunk
|
297
396
|
|
397
|
+
# Extract all complete events from the buffer
|
398
|
+
event_buffers = []
|
298
399
|
while (event_end = @buffer.index("\n\n"))
|
299
400
|
event_data = @buffer.slice!(0, event_end + 2)
|
300
|
-
|
401
|
+
event_buffers << event_data
|
301
402
|
end
|
302
403
|
end
|
303
404
|
|
304
|
-
|
405
|
+
# Process extracted events outside the mutex to avoid deadlocks
|
406
|
+
event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
|
407
|
+
end
|
408
|
+
|
409
|
+
# Handle SSE endpoint event
|
410
|
+
# @param data [String] The endpoint path
|
411
|
+
def handle_endpoint_event(data)
|
412
|
+
@mutex.synchronize do
|
413
|
+
@rpc_endpoint = data
|
414
|
+
@sse_connected = true
|
415
|
+
@connection_established = true
|
416
|
+
@connection_cv.broadcast
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
# Check if the error represents an authorization error
|
421
|
+
# @param error_message [String] The error message from the server
|
422
|
+
# @param error_code [Integer, nil] The error code if available
|
423
|
+
# @return [Boolean] True if it's an authorization error
|
424
|
+
def authorization_error?(error_message, error_code)
|
425
|
+
return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
|
426
|
+
return true if [401, -32_000].include?(error_code)
|
427
|
+
|
428
|
+
false
|
429
|
+
end
|
430
|
+
|
431
|
+
# Handle authorization error in SSE message
|
432
|
+
# @param error_message [String] The error message from the server
|
433
|
+
def handle_sse_auth_error_message(error_message)
|
434
|
+
@mutex.synchronize do
|
435
|
+
@auth_error = "Authorization failed: #{error_message}"
|
436
|
+
@connection_established = false
|
437
|
+
@connection_cv.broadcast
|
438
|
+
end
|
439
|
+
|
440
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
|
441
|
+
end
|
442
|
+
|
443
|
+
# Process error messages in SSE responses
|
444
|
+
# @param data [Hash] The parsed SSE message data
|
445
|
+
def process_error_in_message(data)
|
446
|
+
return unless data['error']
|
447
|
+
|
448
|
+
error_message = data['error']['message'] || 'Unknown server error'
|
449
|
+
error_code = data['error']['code']
|
450
|
+
|
451
|
+
# Handle unauthorized errors (close connection immediately)
|
452
|
+
handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
|
453
|
+
|
454
|
+
@logger.error("Server error: #{error_message}")
|
455
|
+
true # Error was processed
|
456
|
+
end
|
457
|
+
|
458
|
+
# Process JSON-RPC notifications
|
459
|
+
# @param data [Hash] The parsed SSE message data
|
460
|
+
# @return [Boolean] True if a notification was processed
|
461
|
+
def process_notification(data)
|
462
|
+
return false unless data['method'] && !data.key?('id')
|
463
|
+
|
464
|
+
@notification_callback&.call(data['method'], data['params'])
|
465
|
+
true
|
466
|
+
end
|
467
|
+
|
468
|
+
# Process JSON-RPC responses
|
469
|
+
# @param data [Hash] The parsed SSE message data
|
470
|
+
# @return [Boolean] True if a response was processed
|
471
|
+
def process_response(data)
|
472
|
+
return false unless data['id']
|
473
|
+
|
474
|
+
@mutex.synchronize do
|
475
|
+
# Store tools data if present
|
476
|
+
@tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
|
477
|
+
|
478
|
+
# Store response for the waiting request
|
479
|
+
if data['error']
|
480
|
+
@sse_results[data['id']] = {
|
481
|
+
'isError' => true,
|
482
|
+
'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
|
483
|
+
}
|
484
|
+
elsif data['result']
|
485
|
+
@sse_results[data['id']] = data['result']
|
486
|
+
end
|
487
|
+
end
|
488
|
+
|
489
|
+
true
|
305
490
|
end
|
306
491
|
|
307
492
|
# Parse and handle an SSE event
|
@@ -312,38 +497,35 @@ module MCPClient
|
|
312
497
|
|
313
498
|
case event[:event]
|
314
499
|
when 'endpoint'
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
@connection_established = true
|
319
|
-
@connection_cv.broadcast
|
320
|
-
end
|
500
|
+
handle_endpoint_event(event[:data])
|
501
|
+
when 'ping'
|
502
|
+
# Received ping event, no action needed
|
321
503
|
when 'message'
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
if data['method'] && !data.key?('id')
|
326
|
-
@notification_callback&.call(data['method'], data['params'])
|
327
|
-
return
|
328
|
-
end
|
504
|
+
handle_message_event(event)
|
505
|
+
end
|
506
|
+
end
|
329
507
|
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
508
|
+
# Handle a message event from SSE
|
509
|
+
# @param event [Hash] The parsed SSE event
|
510
|
+
def handle_message_event(event)
|
511
|
+
return if event[:data].empty?
|
512
|
+
|
513
|
+
begin
|
514
|
+
data = JSON.parse(event[:data])
|
515
|
+
|
516
|
+
# Process the message in order of precedence
|
517
|
+
return if process_error_in_message(data)
|
518
|
+
|
519
|
+
return if process_notification(data)
|
520
|
+
|
521
|
+
process_response(data)
|
522
|
+
rescue MCPClient::Errors::ConnectionError
|
523
|
+
# Re-raise connection errors to propagate to the calling code
|
524
|
+
raise
|
525
|
+
rescue JSON::ParserError => e
|
526
|
+
@logger.warn("Failed to parse JSON from event data: #{e.message}")
|
527
|
+
rescue StandardError => e
|
528
|
+
@logger.error("Error processing SSE event: #{e.message}")
|
347
529
|
end
|
348
530
|
end
|
349
531
|
|
@@ -351,14 +533,19 @@ module MCPClient
|
|
351
533
|
# @param event_data [String] the event data to parse
|
352
534
|
# @return [Hash, nil] the parsed event, or nil if the event is invalid
|
353
535
|
def parse_sse_event(event_data)
|
354
|
-
@logger.debug("Parsing SSE event data: #{event_data.inspect}")
|
355
536
|
event = { event: 'message', data: '', id: nil }
|
356
537
|
data_lines = []
|
538
|
+
has_content = false
|
357
539
|
|
358
540
|
event_data.each_line do |line|
|
359
541
|
line = line.chomp
|
360
542
|
next if line.empty?
|
361
543
|
|
544
|
+
# Skip SSE comments (lines starting with colon)
|
545
|
+
next if line.start_with?(':')
|
546
|
+
|
547
|
+
has_content = true
|
548
|
+
|
362
549
|
if line.start_with?('event:')
|
363
550
|
event[:event] = line[6..].strip
|
364
551
|
elsif line.start_with?('data:')
|
@@ -369,8 +556,9 @@ module MCPClient
|
|
369
556
|
end
|
370
557
|
|
371
558
|
event[:data] = data_lines.join("\n")
|
372
|
-
|
373
|
-
event
|
559
|
+
|
560
|
+
# Return the event even if data is empty as long as we had non-comment content
|
561
|
+
has_content ? event : nil
|
374
562
|
end
|
375
563
|
|
376
564
|
# Request the tools list using JSON-RPC
|
data/lib/mcp_client/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby-mcp-client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Szymon Kurcab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-05-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|