ruby-mcp-client 0.5.2 → 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 +15 -2
- data/lib/mcp_client/client.rb +6 -0
- data/lib/mcp_client/server_factory.rb +1 -0
- data/lib/mcp_client/server_sse.rb +96 -3
- 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
|
@@ -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
|
@@ -51,6 +59,10 @@ module MCPClient
|
|
51
59
|
@auth_error = nil
|
52
60
|
# Whether to use SSE transport; may disable if handshake fails
|
53
61
|
@use_sse = true
|
62
|
+
|
63
|
+
# Time of last activity
|
64
|
+
@last_activity_time = Time.now
|
65
|
+
@activity_timer_thread = nil
|
54
66
|
end
|
55
67
|
|
56
68
|
# Stream tool call fallback for SSE transport (yields single result)
|
@@ -138,6 +150,7 @@ module MCPClient
|
|
138
150
|
start_sse_thread
|
139
151
|
effective_timeout = [@read_timeout || 30, 30].min
|
140
152
|
wait_for_connection(timeout: effective_timeout)
|
153
|
+
start_activity_monitor
|
141
154
|
true
|
142
155
|
rescue MCPClient::Errors::ConnectionError => e
|
143
156
|
cleanup
|
@@ -169,6 +182,13 @@ module MCPClient
|
|
169
182
|
end
|
170
183
|
@sse_thread = nil
|
171
184
|
|
185
|
+
begin
|
186
|
+
@activity_timer_thread&.kill
|
187
|
+
rescue StandardError
|
188
|
+
nil
|
189
|
+
end
|
190
|
+
@activity_timer_thread = nil
|
191
|
+
|
172
192
|
if @http_client
|
173
193
|
@http_client.finish if @http_client.started?
|
174
194
|
@http_client = nil
|
@@ -227,8 +247,56 @@ module MCPClient
|
|
227
247
|
raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
|
228
248
|
end
|
229
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
|
+
|
230
256
|
private
|
231
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
|
+
|
232
300
|
# Wait for SSE connection to be established with periodic checks
|
233
301
|
# @param timeout [Integer] Maximum time to wait in seconds
|
234
302
|
# @raise [MCPClient::Errors::ConnectionError] if timeout expires
|
@@ -368,6 +436,9 @@ module MCPClient
|
|
368
436
|
def process_sse_chunk(chunk)
|
369
437
|
@logger.debug("Processing SSE chunk: #{chunk.inspect}")
|
370
438
|
|
439
|
+
# Only record activity for real events
|
440
|
+
record_activity if chunk.include?('event:')
|
441
|
+
|
371
442
|
# Check for direct JSON error responses (which aren't proper SSE events)
|
372
443
|
if chunk.start_with?('{') && chunk.include?('"error"') &&
|
373
444
|
(chunk.include?('Unauthorized') || chunk.include?('authentication'))
|
@@ -619,6 +690,10 @@ module MCPClient
|
|
619
690
|
# @return [Hash] the result of the request
|
620
691
|
def send_jsonrpc_request(request)
|
621
692
|
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
693
|
+
|
694
|
+
# Record activity when sending a request
|
695
|
+
record_activity
|
696
|
+
|
622
697
|
uri = URI.parse(@base_url)
|
623
698
|
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
624
699
|
rpc_ep = @mutex.synchronize { @rpc_endpoint }
|
@@ -643,6 +718,9 @@ module MCPClient
|
|
643
718
|
end
|
644
719
|
@logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
|
645
720
|
|
721
|
+
# Record activity when receiving a response
|
722
|
+
record_activity
|
723
|
+
|
646
724
|
unless response.success?
|
647
725
|
raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
|
648
726
|
end
|
@@ -651,17 +729,32 @@ module MCPClient
|
|
651
729
|
# Wait for result via SSE channel
|
652
730
|
request_id = request[:id]
|
653
731
|
start_time = Time.now
|
732
|
+
# Use the specified read_timeout for the overall operation
|
654
733
|
timeout = @read_timeout || 10
|
734
|
+
|
735
|
+
# Check every 100ms for the result, with a total timeout from read_timeout
|
655
736
|
loop do
|
656
737
|
result = nil
|
657
738
|
@mutex.synchronize do
|
658
739
|
result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
|
659
740
|
end
|
660
|
-
return result if result
|
661
|
-
break if Time.now - start_time > timeout
|
662
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
|
663
755
|
sleep 0.1
|
664
756
|
end
|
757
|
+
|
665
758
|
raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
|
666
759
|
else
|
667
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-05-
|
11
|
+
date: 2025-05-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|