ruby-mcp-client 0.2.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 -20
- data/lib/mcp_client/client.rb +20 -0
- data/lib/mcp_client/server_base.rb +24 -0
- data/lib/mcp_client/server_factory.rb +5 -8
- 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
- data/lib/mcp_client.rb +0 -1
- metadata +2 -3
- data/lib/mcp_client/server_http.rb +0 -121
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
@@ -30,7 +30,6 @@ via different transport mechanisms:
|
|
30
30
|
|
31
31
|
- **Standard I/O**: Local processes implementing the MCP protocol
|
32
32
|
- **Server-Sent Events (SSE)**: Remote MCP servers over HTTP with streaming support
|
33
|
-
- **HTTP JSON-RPC**: Remote MCP servers over standard HTTP
|
34
33
|
|
35
34
|
The core client resides in `MCPClient::Client` and provides helper methods for integrating
|
36
35
|
with popular AI services with built-in conversions:
|
@@ -53,15 +52,10 @@ client = MCPClient.create_client(
|
|
53
52
|
MCPClient.sse_config(
|
54
53
|
base_url: 'https://api.example.com/sse',
|
55
54
|
headers: { 'Authorization' => 'Bearer YOUR_TOKEN' },
|
56
|
-
read_timeout: 30 # Optional timeout in seconds (default: 30)
|
57
|
-
),
|
58
|
-
# Remote HTTP JSON-RPC server
|
59
|
-
MCPClient.http_config(
|
60
|
-
base_url: 'https://api.example.com/jsonrpc',
|
61
|
-
headers: { 'Authorization' => 'Bearer YOUR_TOKEN' },
|
62
55
|
read_timeout: 30, # Optional timeout in seconds (default: 30)
|
63
56
|
retries: 3, # Optional number of retry attempts (default: 0)
|
64
57
|
retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
|
58
|
+
# Native support for tool streaming via call_tool_streaming method
|
65
59
|
)
|
66
60
|
]
|
67
61
|
)
|
@@ -82,7 +76,8 @@ results = client.call_tools([
|
|
82
76
|
{ name: 'tool2', parameters: { key2: 'value2' } }
|
83
77
|
])
|
84
78
|
|
85
|
-
# Stream results (
|
79
|
+
# Stream results (supported by the SSE transport)
|
80
|
+
# Returns an Enumerator that yields results as they become available
|
86
81
|
client.call_tool_streaming('streaming_tool', { param: 'value' }).each do |chunk|
|
87
82
|
# Process each chunk as it arrives
|
88
83
|
puts chunk
|
@@ -92,6 +87,18 @@ end
|
|
92
87
|
openai_tools = client.to_openai_tools
|
93
88
|
anthropic_tools = client.to_anthropic_tools
|
94
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
|
+
|
95
102
|
# Clear cached tools to force fresh fetch on next list
|
96
103
|
client.clear_cache
|
97
104
|
# Clean up connections
|
@@ -199,20 +206,15 @@ This client works with any MCP-compatible server, including:
|
|
199
206
|
|
200
207
|
The SSE client implementation provides these key features:
|
201
208
|
|
202
|
-
- **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
|
203
210
|
- **Thread safety**: All operations are thread-safe using monitors and synchronized access
|
204
211
|
- **Reliable error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
|
205
|
-
- **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport
|
206
|
-
- **Streaming support**: Native streaming for real-time updates
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
- **Resilient connection handling**: Manages HTTP/HTTPS connections with configurable timeouts
|
213
|
-
- **Retry mechanism**: Configurable retry attempts with exponential backoff for transient errors
|
214
|
-
- **Error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
|
215
|
-
- **JSON-RPC over HTTP**: Standard JSON-RPC 2.0 implementation
|
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
|
216
218
|
|
217
219
|
## Requirements
|
218
220
|
|
@@ -227,6 +229,16 @@ To implement a compatible MCP server you must:
|
|
227
229
|
- Respond to `list_tools` requests with a JSON list of tools
|
228
230
|
- Respond to `call_tool` requests by executing the specified tool
|
229
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
|
230
242
|
|
231
243
|
## Tool Schema
|
232
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,18 +9,15 @@ module MCPClient
|
|
9
9
|
def self.create(config)
|
10
10
|
case config[:type]
|
11
11
|
when 'stdio'
|
12
|
-
MCPClient::ServerStdio.new(
|
13
|
-
|
14
|
-
MCPClient::ServerSSE.new(
|
15
|
-
base_url: config[:base_url],
|
16
|
-
headers: config[:headers] || {},
|
17
|
-
read_timeout: config[:read_timeout] || 30,
|
12
|
+
MCPClient::ServerStdio.new(
|
13
|
+
command: config[:command],
|
18
14
|
retries: config[:retries] || 0,
|
19
15
|
retry_backoff: config[:retry_backoff] || 1,
|
16
|
+
read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
|
20
17
|
logger: config[:logger]
|
21
18
|
)
|
22
|
-
when '
|
23
|
-
MCPClient::
|
19
|
+
when 'sse'
|
20
|
+
MCPClient::ServerSSE.new(
|
24
21
|
base_url: config[:base_url],
|
25
22
|
headers: config[:headers] || {},
|
26
23
|
read_timeout: config[:read_timeout] || 30,
|
@@ -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
data/lib/mcp_client.rb
CHANGED
@@ -6,7 +6,6 @@ require_relative 'mcp_client/tool'
|
|
6
6
|
require_relative 'mcp_client/server_base'
|
7
7
|
require_relative 'mcp_client/server_stdio'
|
8
8
|
require_relative 'mcp_client/server_sse'
|
9
|
-
require_relative 'mcp_client/server_http'
|
10
9
|
require_relative 'mcp_client/server_factory'
|
11
10
|
require_relative 'mcp_client/client'
|
12
11
|
require_relative 'mcp_client/version'
|
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
|
@@ -81,7 +81,6 @@ files:
|
|
81
81
|
- lib/mcp_client/errors.rb
|
82
82
|
- lib/mcp_client/server_base.rb
|
83
83
|
- lib/mcp_client/server_factory.rb
|
84
|
-
- lib/mcp_client/server_http.rb
|
85
84
|
- lib/mcp_client/server_sse.rb
|
86
85
|
- lib/mcp_client/server_stdio.rb
|
87
86
|
- lib/mcp_client/tool.rb
|
@@ -1,121 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'uri'
|
4
|
-
require 'net/http'
|
5
|
-
require 'json'
|
6
|
-
require 'openssl'
|
7
|
-
require 'logger'
|
8
|
-
|
9
|
-
module MCPClient
|
10
|
-
# Implementation of MCP server over HTTP JSON-RPC
|
11
|
-
class ServerHTTP < ServerBase
|
12
|
-
attr_reader :base_url, :headers, :read_timeout, :max_retries, :retry_backoff, :logger
|
13
|
-
|
14
|
-
# @param base_url [String] The base URL of the MCP HTTP server
|
15
|
-
# @param headers [Hash] HTTP headers to include in requests
|
16
|
-
# @param read_timeout [Integer] Read timeout in seconds
|
17
|
-
# @param retries [Integer] number of retry attempts on transient errors
|
18
|
-
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
19
|
-
# @param logger [Logger, nil] optional logger
|
20
|
-
def initialize(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1, logger: nil)
|
21
|
-
super()
|
22
|
-
@base_url = base_url
|
23
|
-
@headers = headers
|
24
|
-
@read_timeout = read_timeout
|
25
|
-
@max_retries = retries
|
26
|
-
@retry_backoff = retry_backoff
|
27
|
-
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
28
|
-
@request_id = 0
|
29
|
-
end
|
30
|
-
|
31
|
-
# List available tools
|
32
|
-
# @return [Array<MCPClient::Tool>]
|
33
|
-
def list_tools
|
34
|
-
request_json = jsonrpc_request('tools/list', {})
|
35
|
-
result = send_request(request_json)
|
36
|
-
(result['tools'] || []).map { |td| MCPClient::Tool.from_json(td) }
|
37
|
-
rescue MCPClient::Errors::MCPError
|
38
|
-
raise
|
39
|
-
rescue StandardError => e
|
40
|
-
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
41
|
-
end
|
42
|
-
|
43
|
-
# Call a tool with given parameters
|
44
|
-
# @param tool_name [String]
|
45
|
-
# @param parameters [Hash]
|
46
|
-
# @return [Object] result of invocation
|
47
|
-
def call_tool(tool_name, parameters)
|
48
|
-
request_json = jsonrpc_request('tools/call', { 'name' => tool_name, 'arguments' => parameters })
|
49
|
-
send_request(request_json)
|
50
|
-
rescue MCPClient::Errors::MCPError
|
51
|
-
raise
|
52
|
-
rescue StandardError => e
|
53
|
-
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
54
|
-
end
|
55
|
-
|
56
|
-
# Streaming is not supported over simple HTTP transport; fallback to single response
|
57
|
-
# @param tool_name [String]
|
58
|
-
# @param parameters [Hash]
|
59
|
-
# @return [Enumerator]
|
60
|
-
def call_tool_streaming(tool_name, parameters)
|
61
|
-
Enumerator.new do |yielder|
|
62
|
-
yielder << call_tool(tool_name, parameters)
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
private
|
67
|
-
|
68
|
-
def jsonrpc_request(method, params)
|
69
|
-
@request_id += 1
|
70
|
-
{
|
71
|
-
'jsonrpc' => '2.0',
|
72
|
-
'id' => @request_id,
|
73
|
-
'method' => method,
|
74
|
-
'params' => params
|
75
|
-
}
|
76
|
-
end
|
77
|
-
|
78
|
-
def send_request(request)
|
79
|
-
attempts = 0
|
80
|
-
begin
|
81
|
-
attempts += 1
|
82
|
-
uri = URI.parse(base_url)
|
83
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
84
|
-
if uri.scheme == 'https'
|
85
|
-
http.use_ssl = true
|
86
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
87
|
-
end
|
88
|
-
http.open_timeout = 10
|
89
|
-
http.read_timeout = read_timeout
|
90
|
-
|
91
|
-
@logger.debug("Sending HTTP JSONRPC request: #{request.to_json}")
|
92
|
-
response = http.post(uri.path, request.to_json, default_headers)
|
93
|
-
@logger.debug("Received HTTP response: #{response.code} #{response.body}")
|
94
|
-
|
95
|
-
unless response.is_a?(Net::HTTPSuccess)
|
96
|
-
raise MCPClient::Errors::ServerError, "Server returned error: #{response.code} #{response.message}"
|
97
|
-
end
|
98
|
-
|
99
|
-
data = JSON.parse(response.body)
|
100
|
-
raise MCPClient::Errors::ServerError, data['error']['message'] if data['error']
|
101
|
-
|
102
|
-
data['result']
|
103
|
-
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Timeout::Error => e
|
104
|
-
raise unless attempts <= max_retries
|
105
|
-
|
106
|
-
delay = retry_backoff * (2**(attempts - 1))
|
107
|
-
@logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
|
108
|
-
sleep(delay)
|
109
|
-
retry
|
110
|
-
rescue JSON::ParserError => e
|
111
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response: #{e.message}"
|
112
|
-
end
|
113
|
-
end
|
114
|
-
|
115
|
-
def default_headers
|
116
|
-
h = headers.dup
|
117
|
-
h['Content-Type'] = 'application/json'
|
118
|
-
h
|
119
|
-
end
|
120
|
-
end
|
121
|
-
end
|