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 +4 -4
- data/README.md +16 -3
- data/lib/mcp_client/client.rb +6 -0
- data/lib/mcp_client/server_factory.rb +1 -0
- data/lib/mcp_client/server_sse.rb +351 -70
- data/lib/mcp_client/tool.rb +28 -0
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +3 -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: 2ec468ae4d2033287bfc89a788e87acc878ab833552e98bef88c95176fa6a906
|
4
|
+
data.tar.gz: d6f2f676be6932d8dbe641df40674aa77ef64d1296d9fdf4eb7c892537f2fa0c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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/
|
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.
|
data/lib/mcp_client/client.rb
CHANGED
@@ -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,
|
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
|
135
|
-
return true if @connection_established
|
147
|
+
return true if @mutex.synchronize { @connection_established }
|
136
148
|
|
137
|
-
|
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
|
-
|
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
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
400
|
+
# Reset connection state
|
401
|
+
@mutex.synchronize do
|
402
|
+
@sse_connected = false
|
403
|
+
@connection_established = false
|
269
404
|
end
|
270
405
|
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
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
|
279
|
-
#
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
316
|
-
|
317
|
-
|
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
|
-
|
323
|
-
|
324
|
-
|
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
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
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
|
-
|
373
|
-
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
|
data/lib/mcp_client/tool.rb
CHANGED
@@ -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
|
data/lib/mcp_client/version.rb
CHANGED
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.
|
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-
|
11
|
+
date: 2025-05-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|