ruby-mcp-client 0.5.1 → 0.5.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16280a305c2b9cd19ecc2a71b8f10d3805644015f34f7eaec28d5846f12fa1db
4
- data.tar.gz: 48a791c3a88255c25078234819024dace993f7cfc564485f48680a8768b81238
3
+ metadata.gz: 2ec468ae4d2033287bfc89a788e87acc878ab833552e98bef88c95176fa6a906
4
+ data.tar.gz: d6f2f676be6932d8dbe641df40674aa77ef64d1296d9fdf4eb7c892537f2fa0c
5
5
  SHA512:
6
- metadata.gz: d4155463bcc691725b3a88e29dbb7df75a5b05a9ca7693d12a4fcc6f43bb57df06966089042dcaebb6f9f0a3b429a1eaa3138bb478ad161aad98163eb6de2763
7
- data.tar.gz: 6acb25bd0f1c72d640570823c262f548bda1f09322c137789ad36d5950dc80eda6d66e707b0c69229805ffdb575aea086ae999747d74aef9304ad65c46e67039
6
+ metadata.gz: 49f5427d6ffc9bc5632bed9b56e4c03e70ce4894d087331f12fd340b60ce82a539b9b2a362c1ce8d0b9686d9d9a4dae4bea3d11ec732b8a0ae34a3e898ff43b2
7
+ data.tar.gz: 3e32823ef422661fbcf507caf141d0ce65aedd0661ecb54f3b2a287565b630049d2acdfc4bb7b9a93a965e75403b635e43eb21259f035d8b82810f58e0fff304
data/README.md CHANGED
@@ -36,6 +36,7 @@ with popular AI services with built-in conversions:
36
36
 
37
37
  - `to_openai_tools()` - Formats tools for OpenAI API
38
38
  - `to_anthropic_tools()` - Formats tools for Anthropic Claude API
39
+ - `to_google_tools()` - Formats tools for Google Vertex AI API (automatically removes "$schema" keys not accepted by Vertex AI)
39
40
 
40
41
  ## Usage
41
42
 
@@ -53,6 +54,8 @@ client = MCPClient.create_client(
53
54
  base_url: 'https://api.example.com/sse',
54
55
  headers: { 'Authorization' => 'Bearer YOUR_TOKEN' },
55
56
  read_timeout: 30, # Optional timeout in seconds (default: 30)
57
+ ping: 10, # Optional ping interval in seconds of inactivity (default: 10)
58
+ # Connection closes automatically after inactivity (2.5x ping interval)
56
59
  retries: 3, # Optional number of retry attempts (default: 0)
57
60
  retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
58
61
  # Native support for tool streaming via call_tool_streaming method
@@ -98,6 +101,7 @@ end
98
101
  # Format tools for specific AI services
99
102
  openai_tools = client.to_openai_tools
100
103
  anthropic_tools = client.to_anthropic_tools
104
+ google_tools = client.to_google_tools
101
105
 
102
106
  # Register for server notifications
103
107
  client.on_notification do |server, method, params|
@@ -139,7 +143,10 @@ sse_client = MCPClient.create_client(
139
143
  mcp_server_configs: [
140
144
  MCPClient.sse_config(
141
145
  base_url: 'http://localhost:8931/sse',
142
- read_timeout: 30, # Timeout in seconds
146
+ read_timeout: 30, # Timeout in seconds for request fulfillment
147
+ ping: 10, # Send ping after 10 seconds of inactivity
148
+ # Connection closes automatically after inactivity (2.5x ping interval)
149
+ retries: 2 # Number of retry attempts on transient errors
143
150
  )
144
151
  ]
145
152
  )
@@ -262,6 +269,7 @@ Complete examples can be found in the `examples/` directory:
262
269
  - `ruby_openai_mcp.rb` - Integration with alexrudall/ruby-openai gem
263
270
  - `openai_ruby_mcp.rb` - Integration with official openai/openai-ruby gem
264
271
  - `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
272
+ - `gemini_ai_mcp.rb` - Integration with Google Vertex AI and Gemini models
265
273
  - `mcp_sse_server_example.rb` - SSE transport with Playwright MCP
266
274
 
267
275
  ## MCP Server Compatibility
@@ -298,7 +306,7 @@ You can define MCP server configurations in JSON files for easier management:
298
306
  }
299
307
  ```
300
308
 
301
- A simpler example used in the Playwright demo (found in `examples/playwright_server_definition.json`):
309
+ A simpler example used in the Playwright demo (found in `examples/sample_server_definition.json`):
302
310
 
303
311
  ```json
304
312
  {
@@ -349,6 +357,11 @@ Special configuration options:
349
357
  The SSE client implementation provides these key features:
350
358
 
351
359
  - **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts and retries
360
+ - **Advanced connection management**:
361
+ - **Inactivity tracking**: Monitors connection activity to detect idle connections
362
+ - **Automatic ping**: Sends ping requests after a configurable period of inactivity (default: 10 seconds)
363
+ - **Automatic disconnection**: Closes idle connections after inactivity (2.5× ping interval)
364
+ - **MCP compliant**: Any server communication resets the inactivity timer per specification
352
365
  - **Thread safety**: All operations are thread-safe using monitors and synchronized access
353
366
  - **Reliable error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
354
367
  - **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport with initialize handshake
@@ -411,4 +424,4 @@ This gem is available as open source under the [MIT License](LICENSE).
411
424
  ## Contributing
412
425
 
413
426
  Bug reports and pull requests are welcome on GitHub at
414
- https://github.com/simonx1/ruby-mcp-client.
427
+ https://github.com/simonx1/ruby-mcp-client.
@@ -94,6 +94,12 @@ module MCPClient
94
94
  tools.map(&:to_anthropic_tool)
95
95
  end
96
96
 
97
+ def to_google_tools(tool_names: nil)
98
+ tools = list_tools
99
+ tools = tools.select { |t| tool_names.include?(t.name) } if tool_names
100
+ tools.map(&:to_google_tool)
101
+ end
102
+
97
103
  # Clean up all server connections
98
104
  def cleanup
99
105
  servers.each(&:cleanup)
@@ -21,6 +21,7 @@ module MCPClient
21
21
  base_url: config[:base_url],
22
22
  headers: config[:headers] || {},
23
23
  read_timeout: config[:read_timeout] || 30,
24
+ ping: config[:ping] || 10,
24
25
  retries: config[:retries] || 0,
25
26
  retry_backoff: config[:retry_backoff] || 1,
26
27
  logger: config[:logger]
@@ -11,15 +11,20 @@ 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
+
14
17
  attr_reader :base_url, :tools, :server_info, :capabilities
15
18
 
16
19
  # @param base_url [String] The base URL of the MCP server
17
20
  # @param headers [Hash] Additional headers to include in requests
18
21
  # @param read_timeout [Integer] Read timeout in seconds (default: 30)
22
+ # @param ping [Integer] Time in seconds after which to send ping if no activity (default: 10)
19
23
  # @param retries [Integer] number of retry attempts on transient errors
20
24
  # @param retry_backoff [Numeric] base delay in seconds for exponential backoff
21
25
  # @param logger [Logger, nil] optional logger
22
- def initialize(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1, logger: nil)
26
+ def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
27
+ retries: 0, retry_backoff: 1, logger: nil)
23
28
  super()
24
29
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
25
30
  @logger.progname = self.class.name
@@ -36,6 +41,9 @@ module MCPClient
36
41
  # HTTP client is managed via Faraday
37
42
  @tools = nil
38
43
  @read_timeout = read_timeout
44
+ @ping_interval = ping
45
+ # Set close_after to a multiple of the ping interval
46
+ @close_after = (ping * CLOSE_AFTER_PING_RATIO).to_i
39
47
 
40
48
  # SSE-provided JSON-RPC endpoint path for POST requests
41
49
  @rpc_endpoint = nil
@@ -48,8 +56,13 @@ module MCPClient
48
56
  @connection_established = false
49
57
  @connection_cv = @mutex.new_cond
50
58
  @initialized = false
59
+ @auth_error = nil
51
60
  # Whether to use SSE transport; may disable if handshake fails
52
61
  @use_sse = true
62
+
63
+ # Time of last activity
64
+ @last_activity_time = Time.now
65
+ @activity_timer_thread = nil
53
66
  end
54
67
 
55
68
  # Stream tool call fallback for SSE transport (yields single result)
@@ -131,25 +144,31 @@ module MCPClient
131
144
  # @return [Boolean] true if connection was successful
132
145
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
133
146
  def connect
134
- @mutex.synchronize do
135
- return true if @connection_established
147
+ return true if @mutex.synchronize { @connection_established }
136
148
 
137
- # Start SSE listener using Faraday HTTP client
149
+ begin
138
150
  start_sse_thread
151
+ effective_timeout = [@read_timeout || 30, 30].min
152
+ wait_for_connection(timeout: effective_timeout)
153
+ start_activity_monitor
154
+ true
155
+ rescue MCPClient::Errors::ConnectionError => e
156
+ cleanup
157
+ # Check for stored auth error first, as it's more specific
158
+ auth_error = @mutex.synchronize { @auth_error }
159
+ raise MCPClient::Errors::ConnectionError, auth_error if auth_error
160
+
161
+ raise MCPClient::Errors::ConnectionError, e.message if e.message.include?('Authorization failed')
162
+
163
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
164
+ rescue StandardError => e
165
+ cleanup
166
+ # Check for stored auth error
167
+ auth_error = @mutex.synchronize { @auth_error }
168
+ raise MCPClient::Errors::ConnectionError, auth_error if auth_error
139
169
 
140
- timeout = 10
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
170
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
149
171
  end
150
- rescue StandardError => e
151
- cleanup
152
- raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
153
172
  end
154
173
 
155
174
  # Clean up the server connection
@@ -163,6 +182,13 @@ module MCPClient
163
182
  end
164
183
  @sse_thread = nil
165
184
 
185
+ begin
186
+ @activity_timer_thread&.kill
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ @activity_timer_thread = nil
191
+
166
192
  if @http_client
167
193
  @http_client.finish if @http_client.started?
168
194
  @http_client = nil
@@ -171,6 +197,7 @@ module MCPClient
171
197
  @tools = nil
172
198
  @connection_established = false
173
199
  @sse_connected = false
200
+ # Don't clear auth error as we need it for reporting the correct error
174
201
  end
175
202
  end
176
203
 
@@ -220,8 +247,75 @@ module MCPClient
220
247
  raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
221
248
  end
222
249
 
250
+ # Ping the server to keep the connection alive
251
+ # @return [Hash] the result of the ping request
252
+ def ping
253
+ rpc_request('ping')
254
+ end
255
+
223
256
  private
224
257
 
258
+ # Start the activity monitor thread
259
+ # This thread monitors connection activity and:
260
+ # 1. Sends a ping if there's no activity for @ping_interval seconds
261
+ # 2. Closes the connection if there's no activity for @close_after seconds
262
+ def start_activity_monitor
263
+ return if @activity_timer_thread&.alive?
264
+
265
+ @mutex.synchronize { @last_activity_time = Time.now }
266
+
267
+ @activity_timer_thread = Thread.new do
268
+ loop do
269
+ sleep 1 # Check every second
270
+
271
+ last_activity = nil
272
+ @mutex.synchronize { last_activity = @last_activity_time }
273
+
274
+ time_since_activity = Time.now - last_activity
275
+
276
+ if @close_after && time_since_activity >= @close_after
277
+ @logger.info("Closing connection due to inactivity (#{time_since_activity.round(1)}s)")
278
+ cleanup
279
+ break
280
+ elsif @ping_interval && time_since_activity >= @ping_interval
281
+ begin
282
+ @logger.debug("Sending ping after #{time_since_activity.round(1)}s of inactivity")
283
+ ping
284
+ @mutex.synchronize { @last_activity_time = Time.now }
285
+ rescue StandardError => e
286
+ @logger.error("Error sending ping: #{e.message}")
287
+ end
288
+ end
289
+ end
290
+ rescue StandardError => e
291
+ @logger.error("Activity monitor error: #{e.message}")
292
+ end
293
+ end
294
+
295
+ # Record activity to reset the inactivity timer
296
+ def record_activity
297
+ @mutex.synchronize { @last_activity_time = Time.now }
298
+ end
299
+
300
+ # Wait for SSE connection to be established with periodic checks
301
+ # @param timeout [Integer] Maximum time to wait in seconds
302
+ # @raise [MCPClient::Errors::ConnectionError] if timeout expires
303
+ def wait_for_connection(timeout:)
304
+ @mutex.synchronize do
305
+ deadline = Time.now + timeout
306
+
307
+ until @connection_established
308
+ remaining = [1, deadline - Time.now].min
309
+ break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
310
+ end
311
+
312
+ unless @connection_established
313
+ cleanup
314
+ raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
315
+ end
316
+ end
317
+ end
318
+
225
319
  # Ensure SSE initialization handshake has been performed
226
320
  def ensure_initialized
227
321
  return if @initialized
@@ -253,34 +347,85 @@ module MCPClient
253
347
  @capabilities = result['capabilities'] if result.key?('capabilities')
254
348
  end
255
349
 
350
+ # Set up the SSE connection
351
+ # @param uri [URI] The parsed base URL
352
+ # @return [Faraday::Connection] The configured Faraday connection
353
+ def setup_sse_connection(uri)
354
+ sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
355
+
356
+ @sse_conn ||= Faraday.new(url: sse_base) do |f|
357
+ f.options.open_timeout = 10
358
+ f.options.timeout = nil
359
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
360
+ f.adapter Faraday.default_adapter
361
+ end
362
+
363
+ # Use response handling with status check
364
+ @sse_conn.builder.use Faraday::Response::RaiseError
365
+ @sse_conn
366
+ end
367
+
368
+ # Handle authorization errors from Faraday
369
+ # @param error [Faraday::Error] The authorization error
370
+ # @raise [MCPClient::Errors::ConnectionError] with appropriate message
371
+ def handle_sse_auth_error(error)
372
+ error_message = "Authorization failed: HTTP #{error.response[:status]}"
373
+ @logger.error(error_message)
374
+
375
+ @mutex.synchronize do
376
+ @auth_error = error_message
377
+ @connection_established = false
378
+ @connection_cv.broadcast
379
+ end
380
+ raise MCPClient::Errors::ConnectionError, error_message
381
+ end
382
+
383
+ # Reset connection state and signal waiting threads
384
+ def reset_connection_state
385
+ @mutex.synchronize do
386
+ @connection_established = false
387
+ @connection_cv.broadcast
388
+ end
389
+ end
390
+
256
391
  # Start the SSE thread to listen for events
257
392
  def start_sse_thread
258
393
  return if @sse_thread&.alive?
259
394
 
260
395
  @sse_thread = Thread.new do
261
396
  uri = URI.parse(@base_url)
262
- sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
263
397
  sse_path = uri.request_uri
398
+ conn = setup_sse_connection(uri)
264
399
 
265
- @sse_conn ||= Faraday.new(url: sse_base) do |f|
266
- f.options.open_timeout = 10
267
- f.options.timeout = nil
268
- f.adapter Faraday.default_adapter
400
+ # Reset connection state
401
+ @mutex.synchronize do
402
+ @sse_connected = false
403
+ @connection_established = false
269
404
  end
270
405
 
271
- @sse_conn.get(sse_path) do |req|
272
- @headers.each { |k, v| req.headers[k] = v }
273
- req.options.on_data = proc do |chunk, _bytes|
274
- @logger.debug("SSE chunk received: #{chunk.inspect}")
275
- process_sse_chunk(chunk.dup)
406
+ begin
407
+ conn.get(sse_path) do |req|
408
+ @headers.each { |k, v| req.headers[k] = v }
409
+
410
+ req.options.on_data = proc do |chunk, _bytes|
411
+ process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
412
+ end
276
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
277
419
  end
278
- rescue StandardError
279
- # On any SSE thread error, signal connection established to unblock connect
280
- @mutex.synchronize do
281
- @connection_established = true
282
- @connection_cv.broadcast
283
- end
420
+ rescue MCPClient::Errors::ConnectionError => e
421
+ # Re-raise connection errors to propagate them
422
+ # Signal connect method to stop waiting
423
+ reset_connection_state
424
+ raise e
425
+ rescue StandardError => e
426
+ @logger.error("SSE connection error: #{e.message}")
427
+ # Signal connect method to avoid deadlock
428
+ reset_connection_state
284
429
  ensure
285
430
  @mutex.synchronize { @sse_connected = false }
286
431
  end
@@ -290,18 +435,129 @@ module MCPClient
290
435
  # @param chunk [String] the chunk to process
291
436
  def process_sse_chunk(chunk)
292
437
  @logger.debug("Processing SSE chunk: #{chunk.inspect}")
293
- local_buffer = nil
294
438
 
439
+ # Only record activity for real events
440
+ record_activity if chunk.include?('event:')
441
+
442
+ # Check for direct JSON error responses (which aren't proper SSE events)
443
+ if chunk.start_with?('{') && chunk.include?('"error"') &&
444
+ (chunk.include?('Unauthorized') || chunk.include?('authentication'))
445
+ begin
446
+ data = JSON.parse(chunk)
447
+ if data['error']
448
+ error_message = data['error']['message'] || 'Unknown server error'
449
+
450
+ @mutex.synchronize do
451
+ @auth_error = "Authorization failed: #{error_message}"
452
+
453
+ @connection_established = false
454
+ @connection_cv.broadcast
455
+ end
456
+
457
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
458
+ end
459
+ rescue JSON::ParserError
460
+ # Not valid JSON, process normally
461
+ end
462
+ end
463
+
464
+ event_buffers = nil
295
465
  @mutex.synchronize do
296
466
  @buffer += chunk
297
467
 
468
+ # Extract all complete events from the buffer
469
+ event_buffers = []
298
470
  while (event_end = @buffer.index("\n\n"))
299
471
  event_data = @buffer.slice!(0, event_end + 2)
300
- local_buffer = event_data
472
+ event_buffers << event_data
473
+ end
474
+ end
475
+
476
+ # Process extracted events outside the mutex to avoid deadlocks
477
+ event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
478
+ end
479
+
480
+ # Handle SSE endpoint event
481
+ # @param data [String] The endpoint path
482
+ def handle_endpoint_event(data)
483
+ @mutex.synchronize do
484
+ @rpc_endpoint = data
485
+ @sse_connected = true
486
+ @connection_established = true
487
+ @connection_cv.broadcast
488
+ end
489
+ end
490
+
491
+ # Check if the error represents an authorization error
492
+ # @param error_message [String] The error message from the server
493
+ # @param error_code [Integer, nil] The error code if available
494
+ # @return [Boolean] True if it's an authorization error
495
+ def authorization_error?(error_message, error_code)
496
+ return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
497
+ return true if [401, -32_000].include?(error_code)
498
+
499
+ false
500
+ end
501
+
502
+ # Handle authorization error in SSE message
503
+ # @param error_message [String] The error message from the server
504
+ def handle_sse_auth_error_message(error_message)
505
+ @mutex.synchronize do
506
+ @auth_error = "Authorization failed: #{error_message}"
507
+ @connection_established = false
508
+ @connection_cv.broadcast
509
+ end
510
+
511
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
512
+ end
513
+
514
+ # Process error messages in SSE responses
515
+ # @param data [Hash] The parsed SSE message data
516
+ def process_error_in_message(data)
517
+ return unless data['error']
518
+
519
+ error_message = data['error']['message'] || 'Unknown server error'
520
+ error_code = data['error']['code']
521
+
522
+ # Handle unauthorized errors (close connection immediately)
523
+ handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
524
+
525
+ @logger.error("Server error: #{error_message}")
526
+ true # Error was processed
527
+ end
528
+
529
+ # Process JSON-RPC notifications
530
+ # @param data [Hash] The parsed SSE message data
531
+ # @return [Boolean] True if a notification was processed
532
+ def process_notification(data)
533
+ return false unless data['method'] && !data.key?('id')
534
+
535
+ @notification_callback&.call(data['method'], data['params'])
536
+ true
537
+ end
538
+
539
+ # Process JSON-RPC responses
540
+ # @param data [Hash] The parsed SSE message data
541
+ # @return [Boolean] True if a response was processed
542
+ def process_response(data)
543
+ return false unless data['id']
544
+
545
+ @mutex.synchronize do
546
+ # Store tools data if present
547
+ @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
548
+
549
+ # Store response for the waiting request
550
+ if data['error']
551
+ @sse_results[data['id']] = {
552
+ 'isError' => true,
553
+ 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
554
+ }
555
+ elsif data['result']
556
+ @sse_results[data['id']] = data['result']
301
557
  end
302
558
  end
303
559
 
304
- parse_and_handle_sse_event(local_buffer) if local_buffer
560
+ true
305
561
  end
306
562
 
307
563
  # Parse and handle an SSE event
@@ -312,38 +568,35 @@ module MCPClient
312
568
 
313
569
  case event[:event]
314
570
  when 'endpoint'
315
- ep = event[:data]
316
- @mutex.synchronize do
317
- @rpc_endpoint = ep
318
- @connection_established = true
319
- @connection_cv.broadcast
320
- end
571
+ handle_endpoint_event(event[:data])
572
+ when 'ping'
573
+ # Received ping event, no action needed
321
574
  when 'message'
322
- begin
323
- data = JSON.parse(event[:data])
324
- # Dispatch JSON-RPC notifications (no id, has method)
325
- if data['method'] && !data.key?('id')
326
- @notification_callback&.call(data['method'], data['params'])
327
- return
328
- end
575
+ handle_message_event(event)
576
+ end
577
+ end
329
578
 
330
- @mutex.synchronize do
331
- @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
332
-
333
- if data['id']
334
- if data['error']
335
- @sse_results[data['id']] = {
336
- 'isError' => true,
337
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
338
- }
339
- elsif data['result']
340
- @sse_results[data['id']] = data['result']
341
- end
342
- end
343
- end
344
- rescue JSON::ParserError
345
- nil
346
- end
579
+ # Handle a message event from SSE
580
+ # @param event [Hash] The parsed SSE event
581
+ def handle_message_event(event)
582
+ return if event[:data].empty?
583
+
584
+ begin
585
+ data = JSON.parse(event[:data])
586
+
587
+ # Process the message in order of precedence
588
+ return if process_error_in_message(data)
589
+
590
+ return if process_notification(data)
591
+
592
+ process_response(data)
593
+ rescue MCPClient::Errors::ConnectionError
594
+ # Re-raise connection errors to propagate to the calling code
595
+ raise
596
+ rescue JSON::ParserError => e
597
+ @logger.warn("Failed to parse JSON from event data: #{e.message}")
598
+ rescue StandardError => e
599
+ @logger.error("Error processing SSE event: #{e.message}")
347
600
  end
348
601
  end
349
602
 
@@ -351,14 +604,19 @@ module MCPClient
351
604
  # @param event_data [String] the event data to parse
352
605
  # @return [Hash, nil] the parsed event, or nil if the event is invalid
353
606
  def parse_sse_event(event_data)
354
- @logger.debug("Parsing SSE event data: #{event_data.inspect}")
355
607
  event = { event: 'message', data: '', id: nil }
356
608
  data_lines = []
609
+ has_content = false
357
610
 
358
611
  event_data.each_line do |line|
359
612
  line = line.chomp
360
613
  next if line.empty?
361
614
 
615
+ # Skip SSE comments (lines starting with colon)
616
+ next if line.start_with?(':')
617
+
618
+ has_content = true
619
+
362
620
  if line.start_with?('event:')
363
621
  event[:event] = line[6..].strip
364
622
  elsif line.start_with?('data:')
@@ -369,8 +627,9 @@ module MCPClient
369
627
  end
370
628
 
371
629
  event[:data] = data_lines.join("\n")
372
- @logger.debug("Parsed SSE event: #{event.inspect}")
373
- event[:data].empty? ? nil : event
630
+
631
+ # Return the event even if data is empty as long as we had non-comment content
632
+ has_content ? event : nil
374
633
  end
375
634
 
376
635
  # Request the tools list using JSON-RPC
@@ -431,6 +690,10 @@ module MCPClient
431
690
  # @return [Hash] the result of the request
432
691
  def send_jsonrpc_request(request)
433
692
  @logger.debug("Sending JSON-RPC request: #{request.to_json}")
693
+
694
+ # Record activity when sending a request
695
+ record_activity
696
+
434
697
  uri = URI.parse(@base_url)
435
698
  base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
436
699
  rpc_ep = @mutex.synchronize { @rpc_endpoint }
@@ -455,6 +718,9 @@ module MCPClient
455
718
  end
456
719
  @logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
457
720
 
721
+ # Record activity when receiving a response
722
+ record_activity
723
+
458
724
  unless response.success?
459
725
  raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
460
726
  end
@@ -463,17 +729,32 @@ module MCPClient
463
729
  # Wait for result via SSE channel
464
730
  request_id = request[:id]
465
731
  start_time = Time.now
732
+ # Use the specified read_timeout for the overall operation
466
733
  timeout = @read_timeout || 10
734
+
735
+ # Check every 100ms for the result, with a total timeout from read_timeout
467
736
  loop do
468
737
  result = nil
469
738
  @mutex.synchronize do
470
739
  result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
471
740
  end
472
- return result if result
473
- break if Time.now - start_time > timeout
474
741
 
742
+ if result
743
+ # Record activity when receiving a result
744
+ record_activity
745
+ return result
746
+ end
747
+
748
+ current_time = Time.now
749
+ time_elapsed = current_time - start_time
750
+
751
+ # If we've exceeded the timeout, raise an error
752
+ break if time_elapsed > timeout
753
+
754
+ # Sleep for a short time before checking again
475
755
  sleep 0.1
476
756
  end
757
+
477
758
  raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
478
759
  else
479
760
  begin
@@ -46,5 +46,33 @@ module MCPClient
46
46
  input_schema: @schema
47
47
  }
48
48
  end
49
+
50
+ def to_google_tool
51
+ {
52
+ name: @name,
53
+ description: @description,
54
+ parameters: cleaned_schema(@schema)
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ # Recursively remove "$schema" keys that are not accepted by Vertex AI
61
+ # @param obj [Object] schema element (Hash/Array/other)
62
+ # @return [Object] cleaned schema without "$schema" keys
63
+ def cleaned_schema(obj)
64
+ case obj
65
+ when Hash
66
+ obj.each_with_object({}) do |(k, v), h|
67
+ next if k == '$schema'
68
+
69
+ h[k] = cleaned_schema(v)
70
+ end
71
+ when Array
72
+ obj.map { |v| cleaned_schema(v) }
73
+ else
74
+ obj
75
+ end
76
+ end
49
77
  end
50
78
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.5.1'
5
+ VERSION = '0.5.3'
6
6
  end
data/lib/mcp_client.rb CHANGED
@@ -58,15 +58,17 @@ module MCPClient
58
58
  # @param base_url [String] base URL for the server
59
59
  # @param headers [Hash] HTTP headers to include in requests
60
60
  # @param read_timeout [Integer] read timeout in seconds (default: 30)
61
+ # @param ping [Integer] time in seconds after which to send ping if no activity (default: 10)
61
62
  # @param retries [Integer] number of retry attempts (default: 0)
62
63
  # @param retry_backoff [Integer] backoff delay in seconds (default: 1)
63
64
  # @return [Hash] server configuration
64
- def self.sse_config(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1)
65
+ def self.sse_config(base_url:, headers: {}, read_timeout: 30, ping: 10, retries: 0, retry_backoff: 1)
65
66
  {
66
67
  type: 'sse',
67
68
  base_url: base_url,
68
69
  headers: headers,
69
70
  read_timeout: read_timeout,
71
+ ping: ping,
70
72
  retries: retries,
71
73
  retry_backoff: retry_backoff
72
74
  }
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.1
4
+ version: 0.5.3
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-04-26 00:00:00.000000000 Z
11
+ date: 2025-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday