ruby-mcp-client 0.3.0 → 0.4.0
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 +32 -4
- data/lib/mcp_client/client.rb +20 -0
- data/lib/mcp_client/server_base.rb +24 -0
- data/lib/mcp_client/server_factory.rb +7 -1
- data/lib/mcp_client/server_sse.rb +87 -3
- 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: fe40b5481b8c0b59201585bfe54fb912c71235d4f02ada0c8a32a908155c319d
|
4
|
+
data.tar.gz: a378e9f8bd616e92739d371dfb458fc9385a6f2bbc62287cdf1e705742cfa6e0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 19cb60e6ebf6693a3ee312e592b94e891b568bb67fc675b732d3c32c6452260486fe3f7197a4626e933096cbb52ae4afae8aaed763906fba3041adcadb7ee8ce
|
7
|
+
data.tar.gz: c4c9d8a8b7aaf40d277bd20cb7aa50caf1ade083d6569e9825bb6dc53378d1359ef40e7dab84397416df950887b705abca7738179f639bd965ecb23e3d8186ea
|
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,18 @@ 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
|
+
|
88
102
|
# Clear cached tools to force fresh fetch on next list
|
89
103
|
client.clear_cache
|
90
104
|
# Clean up connections
|
@@ -192,11 +206,15 @@ This client works with any MCP-compatible server, including:
|
|
192
206
|
|
193
207
|
The SSE client implementation provides these key features:
|
194
208
|
|
195
|
-
- **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts
|
209
|
+
- **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts and retries
|
196
210
|
- **Thread safety**: All operations are thread-safe using monitors and synchronized access
|
197
211
|
- **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
|
212
|
+
- **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport with initialize handshake
|
213
|
+
- **Streaming support**: Native streaming for real-time updates via the `call_tool_streaming` method, which returns an Enumerator for processing results as they arrive
|
214
|
+
- **Notification support**: Built-in handling for JSON-RPC notifications with automatic tool cache invalidation and custom notification callback support
|
215
|
+
- **Custom RPC methods**: Send any custom JSON-RPC method or notification through `send_rpc` and `send_notification`
|
216
|
+
- **Configurable retries**: All RPC requests support configurable retries with exponential backoff
|
217
|
+
- **Consistent logging**: Tagged, leveled logging across all components for better debugging
|
200
218
|
|
201
219
|
## Requirements
|
202
220
|
|
@@ -211,6 +229,16 @@ To implement a compatible MCP server you must:
|
|
211
229
|
- Respond to `list_tools` requests with a JSON list of tools
|
212
230
|
- Respond to `call_tool` requests by executing the specified tool
|
213
231
|
- Return results (or errors) in JSON format
|
232
|
+
- Optionally send JSON-RPC notifications for events like tool updates
|
233
|
+
|
234
|
+
### JSON-RPC Notifications
|
235
|
+
|
236
|
+
The client supports JSON-RPC notifications from the server:
|
237
|
+
|
238
|
+
- Default notification handler for `notifications/tools/list_changed` to automatically clear the tool cache
|
239
|
+
- Custom notification handling via the `on_notification` method
|
240
|
+
- Callbacks receive the server instance, method name, and parameters
|
241
|
+
- Multiple notification listeners can be registered
|
214
242
|
|
215
243
|
## Tool Schema
|
216
244
|
|
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
|
@@ -27,5 +27,29 @@ 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
|
+
# Register a callback to receive JSON-RPC notifications
|
49
|
+
# @yield [method, params] invoked when a notification is received
|
50
|
+
# @return [void]
|
51
|
+
def on_notification(&block)
|
52
|
+
@notification_callback = block
|
53
|
+
end
|
30
54
|
end
|
31
55
|
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,6 +22,8 @@ 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
|
@base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
|
@@ -42,6 +44,7 @@ module MCPClient
|
|
42
44
|
@sse_connected = false
|
43
45
|
@connection_established = false
|
44
46
|
@connection_cv = @mutex.new_cond
|
47
|
+
@initialized = false
|
45
48
|
end
|
46
49
|
|
47
50
|
# Stream tool call fallback for SSE transport (yields single result)
|
@@ -64,7 +67,7 @@ module MCPClient
|
|
64
67
|
return @tools if @tools
|
65
68
|
end
|
66
69
|
|
67
|
-
|
70
|
+
ensure_initialized
|
68
71
|
|
69
72
|
begin
|
70
73
|
tools_data = request_tools_list
|
@@ -93,7 +96,7 @@ module MCPClient
|
|
93
96
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
94
97
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
95
98
|
def call_tool(tool_name, parameters)
|
96
|
-
|
99
|
+
ensure_initialized
|
97
100
|
|
98
101
|
begin
|
99
102
|
request_id = @mutex.synchronize { @request_id += 1 }
|
@@ -179,8 +182,84 @@ module MCPClient
|
|
179
182
|
end
|
180
183
|
end
|
181
184
|
|
185
|
+
# Generic JSON-RPC request: send method with params and return result
|
186
|
+
# @param method [String] JSON-RPC method name
|
187
|
+
# @param params [Hash] parameters for the request
|
188
|
+
# @return [Object] result from JSON-RPC response
|
189
|
+
def rpc_request(method, params = {})
|
190
|
+
ensure_initialized
|
191
|
+
with_retry do
|
192
|
+
request_id = @mutex.synchronize { @request_id += 1 }
|
193
|
+
request = { jsonrpc: '2.0', id: request_id, method: method, params: params }
|
194
|
+
send_jsonrpc_request(request)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Send a JSON-RPC notification (no response expected)
|
199
|
+
# @param method [String] JSON-RPC method name
|
200
|
+
# @param params [Hash] parameters for the notification
|
201
|
+
# @return [void]
|
202
|
+
def rpc_notify(method, params = {})
|
203
|
+
ensure_initialized
|
204
|
+
url_base = @base_url.sub(%r{/sse/?$}, '')
|
205
|
+
uri = URI.parse("#{url_base}/messages?sessionId=#{@session_id}")
|
206
|
+
rpc_http = Net::HTTP.new(uri.host, uri.port)
|
207
|
+
if uri.scheme == 'https'
|
208
|
+
rpc_http.use_ssl = true
|
209
|
+
rpc_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
210
|
+
end
|
211
|
+
rpc_http.open_timeout = 10
|
212
|
+
rpc_http.read_timeout = @read_timeout
|
213
|
+
rpc_http.keep_alive_timeout = 60
|
214
|
+
rpc_http.start do |http|
|
215
|
+
http_req = Net::HTTP::Post.new(uri)
|
216
|
+
http_req.content_type = 'application/json'
|
217
|
+
http_req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
|
218
|
+
headers = @headers.dup
|
219
|
+
headers.except('Accept', 'Cache-Control').each { |k, v| http_req[k] = v }
|
220
|
+
response = http.request(http_req)
|
221
|
+
unless response.is_a?(Net::HTTPSuccess)
|
222
|
+
raise MCPClient::Errors::ServerError, "Notification failed: #{response.code} #{response.message}"
|
223
|
+
end
|
224
|
+
end
|
225
|
+
rescue StandardError => e
|
226
|
+
raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
|
227
|
+
ensure
|
228
|
+
rpc_http.finish if rpc_http&.started?
|
229
|
+
end
|
230
|
+
|
182
231
|
private
|
183
232
|
|
233
|
+
# Ensure handshake initialization has been performed
|
234
|
+
def ensure_initialized
|
235
|
+
return if @initialized
|
236
|
+
|
237
|
+
connect
|
238
|
+
perform_initialize
|
239
|
+
@initialized = true
|
240
|
+
end
|
241
|
+
|
242
|
+
# Perform JSON-RPC initialize handshake with the MCP server
|
243
|
+
def perform_initialize
|
244
|
+
request_id = @mutex.synchronize { @request_id += 1 }
|
245
|
+
json_rpc_request = {
|
246
|
+
jsonrpc: '2.0',
|
247
|
+
id: request_id,
|
248
|
+
method: 'initialize',
|
249
|
+
params: {
|
250
|
+
'protocolVersion' => MCPClient::VERSION,
|
251
|
+
'capabilities' => {},
|
252
|
+
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
253
|
+
}
|
254
|
+
}
|
255
|
+
@logger.debug("Performing initialize RPC: #{json_rpc_request}")
|
256
|
+
result = send_jsonrpc_request(json_rpc_request)
|
257
|
+
return unless result.is_a?(Hash)
|
258
|
+
|
259
|
+
@server_info = result['serverInfo'] if result.key?('serverInfo')
|
260
|
+
@capabilities = result['capabilities'] if result.key?('capabilities')
|
261
|
+
end
|
262
|
+
|
184
263
|
# Start the SSE thread to listen for events
|
185
264
|
def start_sse_thread
|
186
265
|
return if @sse_thread&.alive?
|
@@ -272,6 +351,11 @@ module MCPClient
|
|
272
351
|
when 'message'
|
273
352
|
begin
|
274
353
|
data = JSON.parse(event[:data])
|
354
|
+
# Dispatch JSON-RPC notifications (no id, has method)
|
355
|
+
if data['method'] && !data.key?('id')
|
356
|
+
@notification_callback&.call(data['method'], data['params'])
|
357
|
+
return
|
358
|
+
end
|
275
359
|
|
276
360
|
@mutex.synchronize do
|
277
361
|
@tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
|
@@ -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.0
|
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
|