ruby-mcp-client 0.4.0 → 0.5.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 +79 -3
- data/lib/mcp_client/client.rb +90 -3
- data/lib/mcp_client/server_base.rb +6 -0
- data/lib/mcp_client/server_sse.rb +103 -155
- data/lib/mcp_client/version.rb +1 -1
- metadata +31 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 79a86302428257274c4e620fae0b7ad45c6229cf381a956ef3b0a7e736f17f62
|
4
|
+
data.tar.gz: 80727b0aa48a992054dafd7c484eb611404b47d878ef2869e59e937a815718dc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 977acc1ae8ca48a17b7827f006ed82578eb7bc24bf9ba08b03e77493e3447febc82287242a410861fb675b1fb74b0b67525dc4f92c9f8b448cd1cad2e3afc875
|
7
|
+
data.tar.gz: f940ed03a52065a5d8687d1722a588693d69a9c69f820771b328fed3b1bb69068fc7110dbedc745ea4fc42d047afa8afe975b2ed821aa5d33ac8c4e25fac9da7
|
data/README.md
CHANGED
@@ -99,12 +99,71 @@ client.send_rpc('custom_method', params: { key: 'value' }, server: :sse) # Uses
|
|
99
99
|
result = client.send_rpc('another_method', params: { data: 123 }) # Uses first available server
|
100
100
|
client.send_notification('status_update', params: { status: 'ready' })
|
101
101
|
|
102
|
+
# Check server connectivity
|
103
|
+
client.ping # Basic connectivity check (zero-parameter heartbeat call)
|
104
|
+
client.ping(server_index: 1) # Ping a specific server by index
|
105
|
+
|
102
106
|
# Clear cached tools to force fresh fetch on next list
|
103
107
|
client.clear_cache
|
104
108
|
# Clean up connections
|
105
109
|
client.cleanup
|
106
110
|
```
|
107
111
|
|
112
|
+
### Server-Sent Events (SSE) Example
|
113
|
+
|
114
|
+
The SSE transport provides robust connection handling for remote MCP servers:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
require 'mcp_client'
|
118
|
+
require 'logger'
|
119
|
+
|
120
|
+
# Optional logger for debugging
|
121
|
+
logger = Logger.new($stdout)
|
122
|
+
logger.level = Logger::INFO
|
123
|
+
|
124
|
+
# Create an MCP client that connects to a Playwright MCP server via SSE
|
125
|
+
# First run: npx @playwright/mcp@latest --port 8931
|
126
|
+
sse_client = MCPClient.create_client(
|
127
|
+
mcp_server_configs: [
|
128
|
+
MCPClient.sse_config(
|
129
|
+
base_url: 'http://localhost:8931/sse',
|
130
|
+
read_timeout: 30, # Timeout in seconds
|
131
|
+
)
|
132
|
+
]
|
133
|
+
)
|
134
|
+
|
135
|
+
# List available tools
|
136
|
+
tools = sse_client.list_tools
|
137
|
+
|
138
|
+
# Launch a browser
|
139
|
+
result = sse_client.call_tool('browser_install', {})
|
140
|
+
result = sse_client.call_tool('browser_navigate', { url: 'about:blank' })
|
141
|
+
# No browser ID needed with these tool names
|
142
|
+
|
143
|
+
# Create a new page
|
144
|
+
page_result = sse_client.call_tool('browser_tab_new', {})
|
145
|
+
# No page ID needed with these tool names
|
146
|
+
|
147
|
+
# Navigate to a website
|
148
|
+
sse_client.call_tool('browser_navigate', { url: 'https://example.com' })
|
149
|
+
|
150
|
+
# Get page title
|
151
|
+
title_result = sse_client.call_tool('browser_snapshot', {})
|
152
|
+
puts "Page snapshot: #{title_result}"
|
153
|
+
|
154
|
+
# Take a screenshot
|
155
|
+
screenshot_result = sse_client.call_tool('browser_take_screenshot', {})
|
156
|
+
|
157
|
+
# Ping the server to verify connectivity
|
158
|
+
ping_result = sse_client.ping
|
159
|
+
puts "Ping successful: #{ping_result.inspect}"
|
160
|
+
|
161
|
+
# Clean up
|
162
|
+
sse_client.cleanup
|
163
|
+
```
|
164
|
+
|
165
|
+
See `examples/mcp_sse_server_example.rb` for the full Playwright SSE example.
|
166
|
+
|
108
167
|
### Integration Examples
|
109
168
|
|
110
169
|
The repository includes examples for integrating with popular AI APIs:
|
@@ -191,6 +250,7 @@ Complete examples can be found in the `examples/` directory:
|
|
191
250
|
- `ruby_openai_mcp.rb` - Integration with alexrudall/ruby-openai gem
|
192
251
|
- `openai_ruby_mcp.rb` - Integration with official openai/openai-ruby gem
|
193
252
|
- `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
|
253
|
+
- `mcp_sse_server_example.rb` - SSE transport with Playwright MCP
|
194
254
|
|
195
255
|
## MCP Server Compatibility
|
196
256
|
|
@@ -200,7 +260,20 @@ This client works with any MCP-compatible server, including:
|
|
200
260
|
- [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
|
201
261
|
- Custom servers implementing the MCP protocol
|
202
262
|
|
203
|
-
|
263
|
+
## Key Features
|
264
|
+
|
265
|
+
### Client Features
|
266
|
+
|
267
|
+
- **Multiple transports** - Support for both stdio and SSE transports
|
268
|
+
- **Multiple servers** - Connect to multiple MCP servers simultaneously
|
269
|
+
- **Tool discovery** - Find tools by name or pattern
|
270
|
+
- **Atomic tool calls** - Simple API for invoking tools with parameters
|
271
|
+
- **Batch support** - Call multiple tools in a single operation
|
272
|
+
- **API conversions** - Built-in format conversion for OpenAI and Anthropic APIs
|
273
|
+
- **Thread safety** - Synchronized access for thread-safe operation
|
274
|
+
- **Server notifications** - Support for JSON-RPC notifications
|
275
|
+
- **Custom RPC methods** - Send any custom JSON-RPC method
|
276
|
+
- **Consistent error handling** - Rich error types for better exception handling
|
204
277
|
|
205
278
|
### Server-Sent Events (SSE) Implementation
|
206
279
|
|
@@ -215,10 +288,13 @@ The SSE client implementation provides these key features:
|
|
215
288
|
- **Custom RPC methods**: Send any custom JSON-RPC method or notification through `send_rpc` and `send_notification`
|
216
289
|
- **Configurable retries**: All RPC requests support configurable retries with exponential backoff
|
217
290
|
- **Consistent logging**: Tagged, leveled logging across all components for better debugging
|
291
|
+
- **Graceful fallbacks**: Automatic fallback to synchronous HTTP when SSE connection fails
|
292
|
+
- **URL normalization**: Consistent URL handling that respects user-provided formats
|
293
|
+
- **Server connectivity check**: Built-in `ping` method to test server connectivity and health
|
218
294
|
|
219
295
|
## Requirements
|
220
296
|
|
221
|
-
- Ruby >= 2.
|
297
|
+
- Ruby >= 3.2.0
|
222
298
|
- No runtime dependencies
|
223
299
|
|
224
300
|
## Implementing an MCP Server
|
@@ -266,4 +342,4 @@ This gem is available as open source under the [MIT License](LICENSE).
|
|
266
342
|
## Contributing
|
267
343
|
|
268
344
|
Bug reports and pull requests are welcome on GitHub at
|
269
|
-
https://github.com/simonx1/ruby-mcp-client.
|
345
|
+
https://github.com/simonx1/ruby-mcp-client.
|
data/lib/mcp_client/client.rb
CHANGED
@@ -31,9 +31,9 @@ module MCPClient
|
|
31
31
|
# Register default and user-defined notification handlers on each server
|
32
32
|
@servers.each do |server|
|
33
33
|
server.on_notification do |method, params|
|
34
|
-
# Default
|
35
|
-
|
36
|
-
# Invoke user listeners
|
34
|
+
# Default notification processing (e.g., cache invalidation, logging)
|
35
|
+
process_notification(server, method, params)
|
36
|
+
# Invoke user-defined listeners
|
37
37
|
@notification_listeners.each { |cb| cb.call(server, method, params) }
|
38
38
|
end
|
39
39
|
end
|
@@ -163,8 +163,95 @@ module MCPClient
|
|
163
163
|
end
|
164
164
|
end
|
165
165
|
|
166
|
+
# Ping the MCP server to check connectivity (zero-parameter heartbeat call)
|
167
|
+
# @param server_index [Integer, nil] optional index of a specific server to ping, nil for first available
|
168
|
+
# @return [Object] result from the ping request
|
169
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
170
|
+
def ping(server_index: nil)
|
171
|
+
if server_index.nil?
|
172
|
+
# Ping first available server
|
173
|
+
raise MCPClient::Errors::ServerNotFound, 'No server available for ping' if @servers.empty?
|
174
|
+
|
175
|
+
@servers.first.ping
|
176
|
+
else
|
177
|
+
# Ping specified server
|
178
|
+
if server_index >= @servers.length
|
179
|
+
raise MCPClient::Errors::ServerNotFound,
|
180
|
+
"Server at index #{server_index} not found"
|
181
|
+
end
|
182
|
+
|
183
|
+
@servers[server_index].ping
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Send a raw JSON-RPC request to a server
|
188
|
+
# @param method [String] JSON-RPC method name
|
189
|
+
# @param params [Hash] parameters for the request
|
190
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
191
|
+
# @return [Object] result from the JSON-RPC response
|
192
|
+
def send_rpc(method, params: {}, server: nil)
|
193
|
+
srv = select_server(server)
|
194
|
+
srv.rpc_request(method, params)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Send a raw JSON-RPC notification to a server (no response expected)
|
198
|
+
# @param method [String] JSON-RPC method name
|
199
|
+
# @param params [Hash] parameters for the notification
|
200
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
201
|
+
# @return [void]
|
202
|
+
def send_notification(method, params: {}, server: nil)
|
203
|
+
srv = select_server(server)
|
204
|
+
srv.rpc_notify(method, params)
|
205
|
+
end
|
206
|
+
|
166
207
|
private
|
167
208
|
|
209
|
+
# Process incoming JSON-RPC notifications with default handlers
|
210
|
+
# @param server [MCPClient::ServerBase] the server that emitted the notification
|
211
|
+
# @param method [String] JSON-RPC notification method
|
212
|
+
# @param params [Hash] parameters for the notification
|
213
|
+
# @return [void]
|
214
|
+
def process_notification(server, method, params)
|
215
|
+
case method
|
216
|
+
when 'notifications/tools/list_changed'
|
217
|
+
logger.warn("[#{server.class}] Tool list has changed, clearing tool cache")
|
218
|
+
clear_cache
|
219
|
+
when 'notifications/resources/updated'
|
220
|
+
logger.warn("[#{server.class}] Resource #{params['uri']} updated")
|
221
|
+
when 'notifications/prompts/list_changed'
|
222
|
+
logger.warn("[#{server.class}] Prompt list has changed")
|
223
|
+
when 'notifications/resources/list_changed'
|
224
|
+
logger.warn("[#{server.class}] Resource list has changed")
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Select a server based on index, type, or instance
|
229
|
+
# @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
230
|
+
# @return [MCPClient::ServerBase]
|
231
|
+
def select_server(server_arg)
|
232
|
+
case server_arg
|
233
|
+
when nil
|
234
|
+
raise MCPClient::Errors::ServerNotFound, 'No server available' if @servers.empty?
|
235
|
+
|
236
|
+
@servers.first
|
237
|
+
when Integer
|
238
|
+
@servers.fetch(server_arg) do
|
239
|
+
raise MCPClient::Errors::ServerNotFound, "Server at index #{server_arg} not found"
|
240
|
+
end
|
241
|
+
when String, Symbol
|
242
|
+
key = server_arg.to_s.downcase
|
243
|
+
srv = @servers.find { |s| s.class.name.split('::').last.downcase.end_with?(key) }
|
244
|
+
raise MCPClient::Errors::ServerNotFound, "Server of type #{server_arg} not found" unless srv
|
245
|
+
|
246
|
+
srv
|
247
|
+
else
|
248
|
+
raise ArgumentError, "Invalid server argument: #{server_arg.inspect}" unless @servers.include?(server_arg)
|
249
|
+
|
250
|
+
server_arg
|
251
|
+
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
168
255
|
# Validate parameters against tool JSON schema (checks required properties)
|
169
256
|
# @param tool [MCPClient::Tool] tool definition with schema
|
170
257
|
# @param parameters [Hash] parameters to validate
|
@@ -45,6 +45,12 @@ module MCPClient
|
|
45
45
|
raise NotImplementedError, 'Subclasses must implement rpc_notify'
|
46
46
|
end
|
47
47
|
|
48
|
+
# Ping the MCP server to check connectivity (zero-parameter heartbeat call)
|
49
|
+
# @return [Object] result from the ping request
|
50
|
+
def ping
|
51
|
+
rpc_request('ping')
|
52
|
+
end
|
53
|
+
|
48
54
|
# Register a callback to receive JSON-RPC notifications
|
49
55
|
# @yield [method, params] invoked when a notification is received
|
50
56
|
# @return [void]
|
@@ -1,17 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'uri'
|
4
|
-
require 'net/http'
|
5
4
|
require 'json'
|
6
|
-
require 'openssl'
|
7
5
|
require 'monitor'
|
8
6
|
require 'logger'
|
7
|
+
require 'faraday'
|
8
|
+
require 'faraday/retry'
|
9
9
|
|
10
10
|
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, :
|
14
|
+
attr_reader :base_url, :tools, :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
|
@@ -26,16 +26,19 @@ module MCPClient
|
|
26
26
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
27
27
|
@max_retries = retries
|
28
28
|
@retry_backoff = retry_backoff
|
29
|
-
|
29
|
+
# Normalize base_url: strip any trailing slash, use exactly as provided
|
30
|
+
@base_url = base_url.chomp('/')
|
30
31
|
@headers = headers.merge({
|
31
32
|
'Accept' => 'text/event-stream',
|
32
33
|
'Cache-Control' => 'no-cache',
|
33
34
|
'Connection' => 'keep-alive'
|
34
35
|
})
|
35
|
-
|
36
|
+
# HTTP client is managed via Faraday
|
36
37
|
@tools = nil
|
37
38
|
@read_timeout = read_timeout
|
38
|
-
|
39
|
+
|
40
|
+
# SSE-provided JSON-RPC endpoint path for POST requests
|
41
|
+
@rpc_endpoint = nil
|
39
42
|
@tools_data = nil
|
40
43
|
@request_id = 0
|
41
44
|
@sse_results = {}
|
@@ -45,6 +48,8 @@ module MCPClient
|
|
45
48
|
@connection_established = false
|
46
49
|
@connection_cv = @mutex.new_cond
|
47
50
|
@initialized = false
|
51
|
+
# Whether to use SSE transport; may disable if handshake fails
|
52
|
+
@use_sse = true
|
48
53
|
end
|
49
54
|
|
50
55
|
# Stream tool call fallback for SSE transport (yields single result)
|
@@ -129,19 +134,7 @@ module MCPClient
|
|
129
134
|
@mutex.synchronize do
|
130
135
|
return true if @connection_established
|
131
136
|
|
132
|
-
|
133
|
-
@http_client = Net::HTTP.new(uri.host, uri.port)
|
134
|
-
|
135
|
-
if uri.scheme == 'https'
|
136
|
-
@http_client.use_ssl = true
|
137
|
-
@http_client.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
138
|
-
end
|
139
|
-
|
140
|
-
@http_client.open_timeout = 10
|
141
|
-
@http_client.read_timeout = @read_timeout
|
142
|
-
@http_client.keep_alive_timeout = 60
|
143
|
-
|
144
|
-
@http_client.start
|
137
|
+
# Start SSE listener using Faraday HTTP client
|
145
138
|
start_sse_thread
|
146
139
|
|
147
140
|
timeout = 10
|
@@ -176,7 +169,6 @@ module MCPClient
|
|
176
169
|
end
|
177
170
|
|
178
171
|
@tools = nil
|
179
|
-
@session_id = nil
|
180
172
|
@connection_established = false
|
181
173
|
@sse_connected = false
|
182
174
|
end
|
@@ -201,41 +193,42 @@ module MCPClient
|
|
201
193
|
# @return [void]
|
202
194
|
def rpc_notify(method, params = {})
|
203
195
|
ensure_initialized
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
196
|
+
uri = URI.parse(@base_url)
|
197
|
+
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
198
|
+
rpc_ep = @mutex.synchronize { @rpc_endpoint }
|
199
|
+
@rpc_conn ||= Faraday.new(url: base) do |f|
|
200
|
+
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
201
|
+
f.options.open_timeout = @read_timeout
|
202
|
+
f.options.timeout = @read_timeout
|
203
|
+
f.adapter Faraday.default_adapter
|
210
204
|
end
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
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}"
|
205
|
+
response = @rpc_conn.post(rpc_ep) do |req|
|
206
|
+
req.headers['Content-Type'] = 'application/json'
|
207
|
+
req.headers['Accept'] = 'application/json'
|
208
|
+
(@headers.dup.tap do |h|
|
209
|
+
h.delete('Accept')
|
210
|
+
h.delete('Cache-Control')
|
211
|
+
end).each do |k, v|
|
212
|
+
req.headers[k] = v
|
223
213
|
end
|
214
|
+
req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
|
215
|
+
end
|
216
|
+
unless response.success?
|
217
|
+
raise MCPClient::Errors::ServerError, "Notification failed: #{response.status} #{response.reason_phrase}"
|
224
218
|
end
|
225
219
|
rescue StandardError => e
|
226
220
|
raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
|
227
|
-
ensure
|
228
|
-
rpc_http.finish if rpc_http&.started?
|
229
221
|
end
|
230
222
|
|
231
223
|
private
|
232
224
|
|
233
|
-
# Ensure
|
225
|
+
# Ensure SSE initialization handshake has been performed
|
234
226
|
def ensure_initialized
|
235
227
|
return if @initialized
|
236
228
|
|
237
229
|
connect
|
238
230
|
perform_initialize
|
231
|
+
|
239
232
|
@initialized = true
|
240
233
|
end
|
241
234
|
|
@@ -265,51 +258,31 @@ module MCPClient
|
|
265
258
|
return if @sse_thread&.alive?
|
266
259
|
|
267
260
|
@sse_thread = Thread.new do
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
sse_http = Net::HTTP.new(uri.host, uri.port)
|
272
|
-
|
273
|
-
if uri.scheme == 'https'
|
274
|
-
sse_http.use_ssl = true
|
275
|
-
sse_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
276
|
-
end
|
277
|
-
|
278
|
-
sse_http.open_timeout = 10
|
279
|
-
sse_http.read_timeout = @read_timeout
|
280
|
-
sse_http.keep_alive_timeout = 60
|
281
|
-
|
282
|
-
sse_http.start do |http|
|
283
|
-
request = Net::HTTP::Get.new(uri)
|
284
|
-
@headers.each { |k, v| request[k] = v }
|
285
|
-
|
286
|
-
http.request(request) do |response|
|
287
|
-
unless response.is_a?(Net::HTTPSuccess) && response['content-type']&.start_with?('text/event-stream')
|
288
|
-
@mutex.synchronize do
|
289
|
-
@connection_established = false
|
290
|
-
@connection_cv.broadcast
|
291
|
-
end
|
292
|
-
raise MCPClient::Errors::ServerError, 'Server response not OK or not text/event-stream'
|
293
|
-
end
|
261
|
+
uri = URI.parse(@base_url)
|
262
|
+
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
263
|
+
sse_path = uri.request_uri
|
294
264
|
|
295
|
-
|
296
|
-
|
297
|
-
|
265
|
+
@sse_conn ||= Faraday.new(url: sse_base) do |f|
|
266
|
+
f.options.open_timeout = 10
|
267
|
+
f.options.timeout = nil
|
268
|
+
f.adapter Faraday.default_adapter
|
269
|
+
end
|
298
270
|
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
end
|
305
|
-
rescue StandardError
|
306
|
-
nil
|
307
|
-
ensure
|
308
|
-
sse_http&.finish if sse_http&.started?
|
309
|
-
@mutex.synchronize do
|
310
|
-
@sse_connected = false
|
271
|
+
@sse_conn.get(sse_path) do |req|
|
272
|
+
@headers.each { |k, v| req.headers[k] = v }
|
273
|
+
req.options.on_data = proc do |chunk, _bytes|
|
274
|
+
@logger.debug("SSE chunk received: #{chunk.inspect}")
|
275
|
+
process_sse_chunk(chunk.dup)
|
311
276
|
end
|
312
277
|
end
|
278
|
+
rescue StandardError
|
279
|
+
# On any SSE thread error, signal connection established to unblock connect
|
280
|
+
@mutex.synchronize do
|
281
|
+
@connection_established = true
|
282
|
+
@connection_cv.broadcast
|
283
|
+
end
|
284
|
+
ensure
|
285
|
+
@mutex.synchronize { @sse_connected = false }
|
313
286
|
end
|
314
287
|
end
|
315
288
|
|
@@ -339,14 +312,11 @@ module MCPClient
|
|
339
312
|
|
340
313
|
case event[:event]
|
341
314
|
when 'endpoint'
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
@
|
346
|
-
|
347
|
-
@connection_established = true
|
348
|
-
@connection_cv.broadcast
|
349
|
-
end
|
315
|
+
ep = event[:data]
|
316
|
+
@mutex.synchronize do
|
317
|
+
@rpc_endpoint = ep
|
318
|
+
@connection_established = true
|
319
|
+
@connection_cv.broadcast
|
350
320
|
end
|
351
321
|
when 'message'
|
352
322
|
begin
|
@@ -462,78 +432,56 @@ module MCPClient
|
|
462
432
|
def send_jsonrpc_request(request)
|
463
433
|
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
464
434
|
uri = URI.parse(@base_url)
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
435
|
+
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
436
|
+
rpc_ep = @mutex.synchronize { @rpc_endpoint }
|
437
|
+
|
438
|
+
@rpc_conn ||= Faraday.new(url: base) do |f|
|
439
|
+
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
440
|
+
f.options.open_timeout = @read_timeout
|
441
|
+
f.options.timeout = @read_timeout
|
442
|
+
f.adapter Faraday.default_adapter
|
470
443
|
end
|
471
444
|
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
end
|
485
|
-
|
486
|
-
uri = URI.parse(url)
|
487
|
-
http_request = Net::HTTP::Post.new(uri)
|
488
|
-
http_request.content_type = 'application/json'
|
489
|
-
http_request.body = request.to_json
|
490
|
-
|
491
|
-
headers = @mutex.synchronize { @headers.dup }
|
492
|
-
headers.except('Accept', 'Cache-Control')
|
493
|
-
.each { |k, v| http_request[k] = v }
|
445
|
+
response = @rpc_conn.post(rpc_ep) do |req|
|
446
|
+
req.headers['Content-Type'] = 'application/json'
|
447
|
+
req.headers['Accept'] = 'application/json'
|
448
|
+
(@headers.dup.tap do |h|
|
449
|
+
h.delete('Accept')
|
450
|
+
h.delete('Cache-Control')
|
451
|
+
end).each do |k, v|
|
452
|
+
req.headers[k] = v
|
453
|
+
end
|
454
|
+
req.body = request.to_json
|
455
|
+
end
|
456
|
+
@logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
|
494
457
|
|
495
|
-
|
496
|
-
|
458
|
+
unless response.success?
|
459
|
+
raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
|
460
|
+
end
|
497
461
|
|
498
|
-
|
499
|
-
|
462
|
+
if @use_sse
|
463
|
+
# Wait for result via SSE channel
|
464
|
+
request_id = request[:id]
|
465
|
+
start_time = Time.now
|
466
|
+
timeout = @read_timeout || 10
|
467
|
+
loop do
|
468
|
+
result = nil
|
469
|
+
@mutex.synchronize do
|
470
|
+
result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
|
500
471
|
end
|
472
|
+
return result if result
|
473
|
+
break if Time.now - start_time > timeout
|
501
474
|
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
if @sse_results[request_id]
|
512
|
-
result = @sse_results[request_id]
|
513
|
-
@sse_results.delete(request_id)
|
514
|
-
end
|
515
|
-
end
|
516
|
-
|
517
|
-
break if result || (Time.now - start_time > timeout)
|
518
|
-
|
519
|
-
sleep 0.1
|
520
|
-
end
|
521
|
-
|
522
|
-
return result if result
|
523
|
-
|
524
|
-
raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
|
525
|
-
|
526
|
-
else
|
527
|
-
begin
|
528
|
-
data = JSON.parse(response.body)
|
529
|
-
return data['result']
|
530
|
-
rescue JSON::ParserError => e
|
531
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
532
|
-
end
|
533
|
-
end
|
475
|
+
sleep 0.1
|
476
|
+
end
|
477
|
+
raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
|
478
|
+
else
|
479
|
+
begin
|
480
|
+
data = JSON.parse(response.body)
|
481
|
+
data['result']
|
482
|
+
rescue JSON::ParserError => e
|
483
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
534
484
|
end
|
535
|
-
ensure
|
536
|
-
rpc_http.finish if rpc_http.started?
|
537
485
|
end
|
538
486
|
end
|
539
487
|
end
|
data/lib/mcp_client/version.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,43 @@
|
|
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.5.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-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: faraday
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: faraday-retry
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.0'
|
13
41
|
- !ruby/object:Gem::Dependency
|
14
42
|
name: rdoc
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -98,7 +126,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
98
126
|
requirements:
|
99
127
|
- - ">="
|
100
128
|
- !ruby/object:Gem::Version
|
101
|
-
version: 2.
|
129
|
+
version: 3.2.0
|
102
130
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
131
|
requirements:
|
104
132
|
- - ">="
|