ruby-mcp-client 0.3.0 → 0.4.1
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 +40 -4
- data/lib/mcp_client/client.rb +42 -0
- data/lib/mcp_client/server_base.rb +31 -0
- data/lib/mcp_client/server_factory.rb +7 -1
- data/lib/mcp_client/server_sse.rb +112 -21
- data/lib/mcp_client/server_stdio.rb +52 -2
- 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: 87cf7d5701adff89363dd653d263189bded230a32232bbbb496d41b4092afdec
|
4
|
+
data.tar.gz: 9a3561d2f97f0ef518cf75dfce82a8521140c9cdc381aaef3f3b3658265a7298
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 361f1916a531f14ded3292e15ea4947b0e3715d71d2be71dd24ea198616a82c55e516379a59a455b6c14f9fb7b3b9a1d02bdd457fde0b975ece6320bbb6da23b
|
7
|
+
data.tar.gz: b38270ec5a9ddce3a3689e6c8fb6e86efaa35453b6bcc8a590612d02fe1ef86040d62fd30fb95ff02640bb7b822bf8f473ae24fd571ad696069657f98a9d5c20
|
data/README.md
CHANGED
@@ -55,6 +55,7 @@ client = MCPClient.create_client(
|
|
55
55
|
read_timeout: 30, # Optional timeout in seconds (default: 30)
|
56
56
|
retries: 3, # Optional number of retry attempts (default: 0)
|
57
57
|
retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
|
58
|
+
# Native support for tool streaming via call_tool_streaming method
|
58
59
|
)
|
59
60
|
]
|
60
61
|
)
|
@@ -75,7 +76,8 @@ results = client.call_tools([
|
|
75
76
|
{ name: 'tool2', parameters: { key2: 'value2' } }
|
76
77
|
])
|
77
78
|
|
78
|
-
# Stream results (
|
79
|
+
# Stream results (supported by the SSE transport)
|
80
|
+
# Returns an Enumerator that yields results as they become available
|
79
81
|
client.call_tool_streaming('streaming_tool', { param: 'value' }).each do |chunk|
|
80
82
|
# Process each chunk as it arrives
|
81
83
|
puts chunk
|
@@ -85,6 +87,23 @@ end
|
|
85
87
|
openai_tools = client.to_openai_tools
|
86
88
|
anthropic_tools = client.to_anthropic_tools
|
87
89
|
|
90
|
+
# Register for server notifications
|
91
|
+
client.on_notification do |server, method, params|
|
92
|
+
puts "Server notification: #{server.class} - #{method} - #{params}"
|
93
|
+
# Handle specific notifications based on method name
|
94
|
+
# 'notifications/tools/list_changed' is handled automatically by the client
|
95
|
+
end
|
96
|
+
|
97
|
+
# Send custom JSON-RPC requests or notifications
|
98
|
+
client.send_rpc('custom_method', params: { key: 'value' }, server: :sse) # Uses specific server
|
99
|
+
result = client.send_rpc('another_method', params: { data: 123 }) # Uses first available server
|
100
|
+
client.send_notification('status_update', params: { status: 'ready' })
|
101
|
+
|
102
|
+
# Check server connectivity
|
103
|
+
client.ping # Basic connectivity check
|
104
|
+
client.ping({ echo: "hello" }) # With optional parameters
|
105
|
+
client.ping({}, server_index: 1) # Ping a specific server by index
|
106
|
+
|
88
107
|
# Clear cached tools to force fresh fetch on next list
|
89
108
|
client.clear_cache
|
90
109
|
# Clean up connections
|
@@ -192,11 +211,18 @@ This client works with any MCP-compatible server, including:
|
|
192
211
|
|
193
212
|
The SSE client implementation provides these key features:
|
194
213
|
|
195
|
-
- **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts
|
214
|
+
- **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts and retries
|
196
215
|
- **Thread safety**: All operations are thread-safe using monitors and synchronized access
|
197
216
|
- **Reliable error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
|
198
|
-
- **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport
|
199
|
-
- **Streaming support**: Native streaming for real-time updates
|
217
|
+
- **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport with initialize handshake
|
218
|
+
- **Streaming support**: Native streaming for real-time updates via the `call_tool_streaming` method, which returns an Enumerator for processing results as they arrive
|
219
|
+
- **Notification support**: Built-in handling for JSON-RPC notifications with automatic tool cache invalidation and custom notification callback support
|
220
|
+
- **Custom RPC methods**: Send any custom JSON-RPC method or notification through `send_rpc` and `send_notification`
|
221
|
+
- **Configurable retries**: All RPC requests support configurable retries with exponential backoff
|
222
|
+
- **Consistent logging**: Tagged, leveled logging across all components for better debugging
|
223
|
+
- **Graceful fallbacks**: Automatic fallback to synchronous HTTP when SSE connection fails
|
224
|
+
- **URL normalization**: Consistent URL handling that respects user-provided formats
|
225
|
+
- **Server connectivity check**: Built-in `ping` method to test server connectivity and health
|
200
226
|
|
201
227
|
## Requirements
|
202
228
|
|
@@ -211,6 +237,16 @@ To implement a compatible MCP server you must:
|
|
211
237
|
- Respond to `list_tools` requests with a JSON list of tools
|
212
238
|
- Respond to `call_tool` requests by executing the specified tool
|
213
239
|
- Return results (or errors) in JSON format
|
240
|
+
- Optionally send JSON-RPC notifications for events like tool updates
|
241
|
+
|
242
|
+
### JSON-RPC Notifications
|
243
|
+
|
244
|
+
The client supports JSON-RPC notifications from the server:
|
245
|
+
|
246
|
+
- Default notification handler for `notifications/tools/list_changed` to automatically clear the tool cache
|
247
|
+
- Custom notification handling via the `on_notification` method
|
248
|
+
- Callbacks receive the server instance, method name, and parameters
|
249
|
+
- Multiple notification listeners can be registered
|
214
250
|
|
215
251
|
## Tool Schema
|
216
252
|
|
data/lib/mcp_client/client.rb
CHANGED
@@ -19,11 +19,24 @@ module MCPClient
|
|
19
19
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
20
20
|
def initialize(mcp_server_configs: [], logger: nil)
|
21
21
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
22
|
+
@logger.progname = self.class.name
|
23
|
+
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
22
24
|
@servers = mcp_server_configs.map do |config|
|
23
25
|
@logger.debug("Creating server with config: #{config.inspect}")
|
24
26
|
MCPClient::ServerFactory.create(config)
|
25
27
|
end
|
26
28
|
@tool_cache = {}
|
29
|
+
# JSON-RPC notification listeners
|
30
|
+
@notification_listeners = []
|
31
|
+
# Register default and user-defined notification handlers on each server
|
32
|
+
@servers.each do |server|
|
33
|
+
server.on_notification do |method, params|
|
34
|
+
# Default handling: clear tool cache on tools list change
|
35
|
+
clear_cache if method == 'notifications/tools/list_changed'
|
36
|
+
# Invoke user listeners
|
37
|
+
@notification_listeners.each { |cb| cb.call(server, method, params) }
|
38
|
+
end
|
39
|
+
end
|
27
40
|
end
|
28
41
|
|
29
42
|
# Lists all available tools from all connected MCP servers
|
@@ -92,6 +105,13 @@ module MCPClient
|
|
92
105
|
@tool_cache.clear
|
93
106
|
end
|
94
107
|
|
108
|
+
# Register a callback for JSON-RPC notifications from servers
|
109
|
+
# @yield [server, method, params]
|
110
|
+
# @return [void]
|
111
|
+
def on_notification(&block)
|
112
|
+
@notification_listeners << block
|
113
|
+
end
|
114
|
+
|
95
115
|
# Find all tools whose name matches the given pattern (String or Regexp)
|
96
116
|
# @param pattern [String, Regexp] pattern to match tool names
|
97
117
|
# @return [Array<MCPClient::Tool>] matching tools
|
@@ -143,6 +163,28 @@ module MCPClient
|
|
143
163
|
end
|
144
164
|
end
|
145
165
|
|
166
|
+
# Ping the MCP server to check connectivity
|
167
|
+
# @param params [Hash] optional parameters for the ping request
|
168
|
+
# @param server_index [Integer, nil] optional index of a specific server to ping, nil for first available
|
169
|
+
# @return [Object] result from the ping request
|
170
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
171
|
+
def ping(params = {}, server_index: nil)
|
172
|
+
if server_index.nil?
|
173
|
+
# Ping first available server
|
174
|
+
raise MCPClient::Errors::ServerNotFound, 'No server available for ping' if @servers.empty?
|
175
|
+
|
176
|
+
@servers.first.ping(params)
|
177
|
+
else
|
178
|
+
# Ping specified server
|
179
|
+
if server_index >= @servers.length
|
180
|
+
raise MCPClient::Errors::ServerNotFound,
|
181
|
+
"Server at index #{server_index} not found"
|
182
|
+
end
|
183
|
+
|
184
|
+
@servers[server_index].ping(params)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
146
188
|
private
|
147
189
|
|
148
190
|
# Validate parameters against tool JSON schema (checks required properties)
|
@@ -27,5 +27,36 @@ module MCPClient
|
|
27
27
|
def cleanup
|
28
28
|
raise NotImplementedError, 'Subclasses must implement cleanup'
|
29
29
|
end
|
30
|
+
|
31
|
+
# Send a JSON-RPC request and return the result
|
32
|
+
# @param method [String] JSON-RPC method name
|
33
|
+
# @param params [Hash] parameters for the request
|
34
|
+
# @return [Object] result field from the JSON-RPC response
|
35
|
+
# @raise [MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ToolCallError]
|
36
|
+
def rpc_request(method, params = {})
|
37
|
+
raise NotImplementedError, 'Subclasses must implement rpc_request'
|
38
|
+
end
|
39
|
+
|
40
|
+
# Send a JSON-RPC notification (no response expected)
|
41
|
+
# @param method [String] JSON-RPC method name
|
42
|
+
# @param params [Hash] parameters for the notification
|
43
|
+
# @return [void]
|
44
|
+
def rpc_notify(method, params = {})
|
45
|
+
raise NotImplementedError, 'Subclasses must implement rpc_notify'
|
46
|
+
end
|
47
|
+
|
48
|
+
# Ping the MCP server to check connectivity
|
49
|
+
# @param params [Hash] optional parameters for the ping request
|
50
|
+
# @return [Object] result from the ping request
|
51
|
+
def ping(params = {})
|
52
|
+
rpc_request('ping', params)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Register a callback to receive JSON-RPC notifications
|
56
|
+
# @yield [method, params] invoked when a notification is received
|
57
|
+
# @return [void]
|
58
|
+
def on_notification(&block)
|
59
|
+
@notification_callback = block
|
60
|
+
end
|
30
61
|
end
|
31
62
|
end
|
@@ -9,7 +9,13 @@ module MCPClient
|
|
9
9
|
def self.create(config)
|
10
10
|
case config[:type]
|
11
11
|
when 'stdio'
|
12
|
-
MCPClient::ServerStdio.new(
|
12
|
+
MCPClient::ServerStdio.new(
|
13
|
+
command: config[:command],
|
14
|
+
retries: config[:retries] || 0,
|
15
|
+
retry_backoff: config[:retry_backoff] || 1,
|
16
|
+
read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
|
17
|
+
logger: config[:logger]
|
18
|
+
)
|
13
19
|
when 'sse'
|
14
20
|
MCPClient::ServerSSE.new(
|
15
21
|
base_url: config[:base_url],
|
@@ -11,7 +11,7 @@ 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
|
-
attr_reader :base_url, :tools, :session_id, :http_client
|
14
|
+
attr_reader :base_url, :tools, :session_id, :http_client, :server_info, :capabilities
|
15
15
|
|
16
16
|
# @param base_url [String] The base URL of the MCP server
|
17
17
|
# @param headers [Hash] Additional headers to include in requests
|
@@ -22,9 +22,12 @@ module MCPClient
|
|
22
22
|
def initialize(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1, logger: nil)
|
23
23
|
super()
|
24
24
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
25
|
+
@logger.progname = self.class.name
|
26
|
+
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
25
27
|
@max_retries = retries
|
26
28
|
@retry_backoff = retry_backoff
|
27
|
-
|
29
|
+
# Normalize base_url: strip any trailing slash, use exactly as provided
|
30
|
+
@base_url = base_url.chomp('/')
|
28
31
|
@headers = headers.merge({
|
29
32
|
'Accept' => 'text/event-stream',
|
30
33
|
'Cache-Control' => 'no-cache',
|
@@ -42,6 +45,9 @@ module MCPClient
|
|
42
45
|
@sse_connected = false
|
43
46
|
@connection_established = false
|
44
47
|
@connection_cv = @mutex.new_cond
|
48
|
+
@initialized = false
|
49
|
+
# Whether to use SSE transport; may disable if handshake fails
|
50
|
+
@use_sse = true
|
45
51
|
end
|
46
52
|
|
47
53
|
# Stream tool call fallback for SSE transport (yields single result)
|
@@ -64,7 +70,7 @@ module MCPClient
|
|
64
70
|
return @tools if @tools
|
65
71
|
end
|
66
72
|
|
67
|
-
|
73
|
+
ensure_initialized
|
68
74
|
|
69
75
|
begin
|
70
76
|
tools_data = request_tools_list
|
@@ -93,7 +99,7 @@ module MCPClient
|
|
93
99
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
94
100
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
95
101
|
def call_tool(tool_name, parameters)
|
96
|
-
|
102
|
+
ensure_initialized
|
97
103
|
|
98
104
|
begin
|
99
105
|
request_id = @mutex.synchronize { @request_id += 1 }
|
@@ -179,8 +185,85 @@ module MCPClient
|
|
179
185
|
end
|
180
186
|
end
|
181
187
|
|
188
|
+
# Generic JSON-RPC request: send method with params and return result
|
189
|
+
# @param method [String] JSON-RPC method name
|
190
|
+
# @param params [Hash] parameters for the request
|
191
|
+
# @return [Object] result from JSON-RPC response
|
192
|
+
def rpc_request(method, params = {})
|
193
|
+
ensure_initialized
|
194
|
+
with_retry do
|
195
|
+
request_id = @mutex.synchronize { @request_id += 1 }
|
196
|
+
request = { jsonrpc: '2.0', id: request_id, method: method, params: params }
|
197
|
+
send_jsonrpc_request(request)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Send a JSON-RPC notification (no response expected)
|
202
|
+
# @param method [String] JSON-RPC method name
|
203
|
+
# @param params [Hash] parameters for the notification
|
204
|
+
# @return [void]
|
205
|
+
def rpc_notify(method, params = {})
|
206
|
+
ensure_initialized
|
207
|
+
url_base = @base_url.sub(%r{/sse/?$}, '')
|
208
|
+
uri = URI.parse("#{url_base}/messages?sessionId=#{@session_id}")
|
209
|
+
rpc_http = Net::HTTP.new(uri.host, uri.port)
|
210
|
+
if uri.scheme == 'https'
|
211
|
+
rpc_http.use_ssl = true
|
212
|
+
rpc_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
213
|
+
end
|
214
|
+
rpc_http.open_timeout = 10
|
215
|
+
rpc_http.read_timeout = @read_timeout
|
216
|
+
rpc_http.keep_alive_timeout = 60
|
217
|
+
rpc_http.start do |http|
|
218
|
+
http_req = Net::HTTP::Post.new(uri)
|
219
|
+
http_req.content_type = 'application/json'
|
220
|
+
http_req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
|
221
|
+
headers = @headers.dup
|
222
|
+
headers.except('Accept', 'Cache-Control').each { |k, v| http_req[k] = v }
|
223
|
+
response = http.request(http_req)
|
224
|
+
unless response.is_a?(Net::HTTPSuccess)
|
225
|
+
raise MCPClient::Errors::ServerError, "Notification failed: #{response.code} #{response.message}"
|
226
|
+
end
|
227
|
+
end
|
228
|
+
rescue StandardError => e
|
229
|
+
raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
|
230
|
+
ensure
|
231
|
+
rpc_http.finish if rpc_http&.started?
|
232
|
+
end
|
233
|
+
|
182
234
|
private
|
183
235
|
|
236
|
+
# Ensure SSE initialization handshake has been performed
|
237
|
+
def ensure_initialized
|
238
|
+
return if @initialized
|
239
|
+
|
240
|
+
connect
|
241
|
+
perform_initialize
|
242
|
+
|
243
|
+
@initialized = true
|
244
|
+
end
|
245
|
+
|
246
|
+
# Perform JSON-RPC initialize handshake with the MCP server
|
247
|
+
def perform_initialize
|
248
|
+
request_id = @mutex.synchronize { @request_id += 1 }
|
249
|
+
json_rpc_request = {
|
250
|
+
jsonrpc: '2.0',
|
251
|
+
id: request_id,
|
252
|
+
method: 'initialize',
|
253
|
+
params: {
|
254
|
+
'protocolVersion' => MCPClient::VERSION,
|
255
|
+
'capabilities' => {},
|
256
|
+
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
257
|
+
}
|
258
|
+
}
|
259
|
+
@logger.debug("Performing initialize RPC: #{json_rpc_request}")
|
260
|
+
result = send_jsonrpc_request(json_rpc_request)
|
261
|
+
return unless result.is_a?(Hash)
|
262
|
+
|
263
|
+
@server_info = result['serverInfo'] if result.key?('serverInfo')
|
264
|
+
@capabilities = result['capabilities'] if result.key?('capabilities')
|
265
|
+
end
|
266
|
+
|
184
267
|
# Start the SSE thread to listen for events
|
185
268
|
def start_sse_thread
|
186
269
|
return if @sse_thread&.alive?
|
@@ -207,6 +290,7 @@ module MCPClient
|
|
207
290
|
http.request(request) do |response|
|
208
291
|
unless response.is_a?(Net::HTTPSuccess) && response['content-type']&.start_with?('text/event-stream')
|
209
292
|
@mutex.synchronize do
|
293
|
+
# Signal connection attempt completed (failed)
|
210
294
|
@connection_established = false
|
211
295
|
@connection_cv.broadcast
|
212
296
|
end
|
@@ -214,7 +298,10 @@ module MCPClient
|
|
214
298
|
end
|
215
299
|
|
216
300
|
@mutex.synchronize do
|
301
|
+
# Signal connection established and SSE ready
|
217
302
|
@sse_connected = true
|
303
|
+
@connection_established = true
|
304
|
+
@connection_cv.broadcast
|
218
305
|
end
|
219
306
|
|
220
307
|
response.read_body do |chunk|
|
@@ -224,6 +311,11 @@ module MCPClient
|
|
224
311
|
end
|
225
312
|
end
|
226
313
|
rescue StandardError
|
314
|
+
# On any SSE thread error, signal connection as established to unblock connect
|
315
|
+
@mutex.synchronize do
|
316
|
+
@connection_established = true
|
317
|
+
@connection_cv.broadcast
|
318
|
+
end
|
227
319
|
nil
|
228
320
|
ensure
|
229
321
|
sse_http&.finish if sse_http&.started?
|
@@ -272,6 +364,11 @@ module MCPClient
|
|
272
364
|
when 'message'
|
273
365
|
begin
|
274
366
|
data = JSON.parse(event[:data])
|
367
|
+
# Dispatch JSON-RPC notifications (no id, has method)
|
368
|
+
if data['method'] && !data.key?('id')
|
369
|
+
@notification_callback&.call(data['method'], data['params'])
|
370
|
+
return
|
371
|
+
end
|
275
372
|
|
276
373
|
@mutex.synchronize do
|
277
374
|
@tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
|
@@ -415,37 +512,31 @@ module MCPClient
|
|
415
512
|
raise MCPClient::Errors::ServerError, "Server returned error: #{response.code} #{response.message}"
|
416
513
|
end
|
417
514
|
|
418
|
-
|
515
|
+
# If SSE transport is enabled, retrieve the result via the SSE channel
|
516
|
+
if @use_sse
|
419
517
|
request_id = request[:id]
|
420
|
-
|
421
518
|
start_time = Time.now
|
422
|
-
timeout = 10
|
519
|
+
timeout = @read_timeout || 10
|
423
520
|
result = nil
|
424
521
|
|
425
522
|
loop do
|
426
523
|
@mutex.synchronize do
|
427
|
-
if @sse_results
|
428
|
-
result = @sse_results[request_id]
|
429
|
-
@sse_results.delete(request_id)
|
430
|
-
end
|
524
|
+
result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
|
431
525
|
end
|
432
|
-
|
433
526
|
break if result || (Time.now - start_time > timeout)
|
434
527
|
|
435
528
|
sleep 0.1
|
436
529
|
end
|
437
|
-
|
438
530
|
return result if result
|
439
531
|
|
440
532
|
raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
end
|
533
|
+
end
|
534
|
+
# Fallback: parse synchronous HTTP JSON response
|
535
|
+
begin
|
536
|
+
data = JSON.parse(response.body)
|
537
|
+
return data['result']
|
538
|
+
rescue JSON::ParserError => e
|
539
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
449
540
|
end
|
450
541
|
end
|
451
542
|
ensure
|
@@ -16,8 +16,9 @@ module MCPClient
|
|
16
16
|
# @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
|
17
17
|
# @param retries [Integer] number of retry attempts on transient errors
|
18
18
|
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
19
|
+
# @param read_timeout [Numeric] timeout in seconds for reading responses
|
19
20
|
# @param logger [Logger, nil] optional logger
|
20
|
-
def initialize(command:, retries: 0, retry_backoff: 1, logger: nil)
|
21
|
+
def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOUT, logger: nil)
|
21
22
|
super()
|
22
23
|
@command = command.is_a?(Array) ? command.join(' ') : command
|
23
24
|
@mutex = Mutex.new
|
@@ -26,8 +27,11 @@ module MCPClient
|
|
26
27
|
@pending = {}
|
27
28
|
@initialized = false
|
28
29
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
30
|
+
@logger.progname = self.class.name
|
31
|
+
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
29
32
|
@max_retries = retries
|
30
33
|
@retry_backoff = retry_backoff
|
34
|
+
@read_timeout = read_timeout
|
31
35
|
end
|
32
36
|
|
33
37
|
# Connect to the MCP server by launching the command process via stdout/stdin
|
@@ -57,6 +61,12 @@ module MCPClient
|
|
57
61
|
def handle_line(line)
|
58
62
|
msg = JSON.parse(line)
|
59
63
|
@logger.debug("Received line: #{line.chomp}")
|
64
|
+
# Dispatch JSON-RPC notifications (no id, has method)
|
65
|
+
if msg['method'] && !msg.key?('id')
|
66
|
+
@notification_callback&.call(msg['method'], msg['params'])
|
67
|
+
return
|
68
|
+
end
|
69
|
+
# Handle standard JSON-RPC responses
|
60
70
|
id = msg['id']
|
61
71
|
return unless id
|
62
72
|
|
@@ -188,7 +198,7 @@ module MCPClient
|
|
188
198
|
end
|
189
199
|
|
190
200
|
def wait_response(id)
|
191
|
-
deadline = Time.now +
|
201
|
+
deadline = Time.now + @read_timeout
|
192
202
|
@mutex.synchronize do
|
193
203
|
until @pending.key?(id)
|
194
204
|
remaining = deadline - Time.now
|
@@ -213,5 +223,45 @@ module MCPClient
|
|
213
223
|
yielder << call_tool(tool_name, parameters)
|
214
224
|
end
|
215
225
|
end
|
226
|
+
|
227
|
+
# Generic JSON-RPC request: send method with params and wait for result
|
228
|
+
# @param method [String] JSON-RPC method
|
229
|
+
# @param params [Hash] parameters for the request
|
230
|
+
# @return [Object] result from JSON-RPC response
|
231
|
+
def rpc_request(method, params = {})
|
232
|
+
ensure_initialized
|
233
|
+
attempts = 0
|
234
|
+
begin
|
235
|
+
req_id = next_id
|
236
|
+
req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => method, 'params' => params }
|
237
|
+
send_request(req)
|
238
|
+
res = wait_response(req_id)
|
239
|
+
if (err = res['error'])
|
240
|
+
raise MCPClient::Errors::ServerError, err['message']
|
241
|
+
end
|
242
|
+
|
243
|
+
res['result']
|
244
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
|
245
|
+
Errno::ECONNRESET => e
|
246
|
+
attempts += 1
|
247
|
+
if attempts <= @max_retries
|
248
|
+
delay = @retry_backoff * (2**(attempts - 1))
|
249
|
+
@logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
|
250
|
+
sleep(delay)
|
251
|
+
retry
|
252
|
+
end
|
253
|
+
raise
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
# Send a JSON-RPC notification (no response expected)
|
258
|
+
# @param method [String] JSON-RPC method
|
259
|
+
# @param params [Hash] parameters for the notification
|
260
|
+
# @return [void]
|
261
|
+
def rpc_notify(method, params = {})
|
262
|
+
ensure_initialized
|
263
|
+
notif = { 'jsonrpc' => '2.0', 'method' => method, 'params' => params }
|
264
|
+
@stdin.puts(notif.to_json)
|
265
|
+
end
|
216
266
|
end
|
217
267
|
end
|
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.
|
4
|
+
version: 0.4.1
|
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-
|
11
|
+
date: 2025-04-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rdoc
|