ruby-mcp-client 0.4.1 → 0.5.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 +145 -8
- data/lib/mcp_client/client.rb +73 -8
- data/lib/mcp_client/config_parser.rb +203 -0
- data/lib/mcp_client/server_base.rb +3 -4
- data/lib/mcp_client/server_sse.rb +97 -156
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +31 -4
- metadata +32 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 16280a305c2b9cd19ecc2a71b8f10d3805644015f34f7eaec28d5846f12fa1db
|
4
|
+
data.tar.gz: 48a791c3a88255c25078234819024dace993f7cfc564485f48680a8768b81238
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d4155463bcc691725b3a88e29dbb7df75a5b05a9ca7693d12a4fcc6f43bb57df06966089042dcaebb6f9f0a3b429a1eaa3138bb478ad161aad98163eb6de2763
|
7
|
+
data.tar.gz: 6acb25bd0f1c72d640570823c262f548bda1f09322c137789ad36d5950dc80eda6d66e707b0c69229805ffdb575aea086ae999747d74aef9304ad65c46e67039
|
data/README.md
CHANGED
@@ -56,10 +56,22 @@ client = MCPClient.create_client(
|
|
56
56
|
retries: 3, # Optional number of retry attempts (default: 0)
|
57
57
|
retry_backoff: 1 # Optional backoff delay in seconds (default: 1)
|
58
58
|
# Native support for tool streaming via call_tool_streaming method
|
59
|
-
|
60
|
-
]
|
59
|
+
) ]
|
61
60
|
)
|
62
61
|
|
62
|
+
# Or load server definitions from a JSON file
|
63
|
+
client = MCPClient.create_client(
|
64
|
+
server_definition_file: 'path/to/server_definition.json'
|
65
|
+
)
|
66
|
+
|
67
|
+
# MCP server configuration JSON format can be:
|
68
|
+
# 1. A single server object:
|
69
|
+
# { "type": "sse", "url": "http://example.com/sse" }
|
70
|
+
# 2. An array of server objects:
|
71
|
+
# [{ "type": "stdio", "command": "npx server" }, { "type": "sse", "url": "http://..." }]
|
72
|
+
# 3. An object with "mcpServers" key containing named servers:
|
73
|
+
# { "mcpServers": { "server1": { "type": "sse", "url": "http://..." } } }
|
74
|
+
|
63
75
|
# List available tools
|
64
76
|
tools = client.list_tools
|
65
77
|
|
@@ -100,9 +112,8 @@ result = client.send_rpc('another_method', params: { data: 123 }) # Uses first a
|
|
100
112
|
client.send_notification('status_update', params: { status: 'ready' })
|
101
113
|
|
102
114
|
# Check server connectivity
|
103
|
-
client.ping # Basic connectivity check
|
104
|
-
client.ping(
|
105
|
-
client.ping({}, server_index: 1) # Ping a specific server by index
|
115
|
+
client.ping # Basic connectivity check (zero-parameter heartbeat call)
|
116
|
+
client.ping(server_index: 1) # Ping a specific server by index
|
106
117
|
|
107
118
|
# Clear cached tools to force fresh fetch on next list
|
108
119
|
client.clear_cache
|
@@ -110,6 +121,61 @@ client.clear_cache
|
|
110
121
|
client.cleanup
|
111
122
|
```
|
112
123
|
|
124
|
+
### Server-Sent Events (SSE) Example
|
125
|
+
|
126
|
+
The SSE transport provides robust connection handling for remote MCP servers:
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
require 'mcp_client'
|
130
|
+
require 'logger'
|
131
|
+
|
132
|
+
# Optional logger for debugging
|
133
|
+
logger = Logger.new($stdout)
|
134
|
+
logger.level = Logger::INFO
|
135
|
+
|
136
|
+
# Create an MCP client that connects to a Playwright MCP server via SSE
|
137
|
+
# First run: npx @playwright/mcp@latest --port 8931
|
138
|
+
sse_client = MCPClient.create_client(
|
139
|
+
mcp_server_configs: [
|
140
|
+
MCPClient.sse_config(
|
141
|
+
base_url: 'http://localhost:8931/sse',
|
142
|
+
read_timeout: 30, # Timeout in seconds
|
143
|
+
)
|
144
|
+
]
|
145
|
+
)
|
146
|
+
|
147
|
+
# List available tools
|
148
|
+
tools = sse_client.list_tools
|
149
|
+
|
150
|
+
# Launch a browser
|
151
|
+
result = sse_client.call_tool('browser_install', {})
|
152
|
+
result = sse_client.call_tool('browser_navigate', { url: 'about:blank' })
|
153
|
+
# No browser ID needed with these tool names
|
154
|
+
|
155
|
+
# Create a new page
|
156
|
+
page_result = sse_client.call_tool('browser_tab_new', {})
|
157
|
+
# No page ID needed with these tool names
|
158
|
+
|
159
|
+
# Navigate to a website
|
160
|
+
sse_client.call_tool('browser_navigate', { url: 'https://example.com' })
|
161
|
+
|
162
|
+
# Get page title
|
163
|
+
title_result = sse_client.call_tool('browser_snapshot', {})
|
164
|
+
puts "Page snapshot: #{title_result}"
|
165
|
+
|
166
|
+
# Take a screenshot
|
167
|
+
screenshot_result = sse_client.call_tool('browser_take_screenshot', {})
|
168
|
+
|
169
|
+
# Ping the server to verify connectivity
|
170
|
+
ping_result = sse_client.ping
|
171
|
+
puts "Ping successful: #{ping_result.inspect}"
|
172
|
+
|
173
|
+
# Clean up
|
174
|
+
sse_client.cleanup
|
175
|
+
```
|
176
|
+
|
177
|
+
See `examples/mcp_sse_server_example.rb` for the full Playwright SSE example.
|
178
|
+
|
113
179
|
### Integration Examples
|
114
180
|
|
115
181
|
The repository includes examples for integrating with popular AI APIs:
|
@@ -196,6 +262,7 @@ Complete examples can be found in the `examples/` directory:
|
|
196
262
|
- `ruby_openai_mcp.rb` - Integration with alexrudall/ruby-openai gem
|
197
263
|
- `openai_ruby_mcp.rb` - Integration with official openai/openai-ruby gem
|
198
264
|
- `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
|
265
|
+
- `mcp_sse_server_example.rb` - SSE transport with Playwright MCP
|
199
266
|
|
200
267
|
## MCP Server Compatibility
|
201
268
|
|
@@ -205,7 +272,77 @@ This client works with any MCP-compatible server, including:
|
|
205
272
|
- [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
|
206
273
|
- Custom servers implementing the MCP protocol
|
207
274
|
|
208
|
-
### Server
|
275
|
+
### Server Definition Files
|
276
|
+
|
277
|
+
You can define MCP server configurations in JSON files for easier management:
|
278
|
+
|
279
|
+
```json
|
280
|
+
{
|
281
|
+
"mcpServers": {
|
282
|
+
"playwright": {
|
283
|
+
"type": "sse",
|
284
|
+
"url": "http://localhost:8931/sse",
|
285
|
+
"headers": {
|
286
|
+
"Authorization": "Bearer TOKEN"
|
287
|
+
}
|
288
|
+
},
|
289
|
+
"filesystem": {
|
290
|
+
"type": "stdio",
|
291
|
+
"command": "npx",
|
292
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
293
|
+
"env": {
|
294
|
+
"DEBUG": "true"
|
295
|
+
}
|
296
|
+
}
|
297
|
+
}
|
298
|
+
}
|
299
|
+
```
|
300
|
+
|
301
|
+
A simpler example used in the Playwright demo (found in `examples/playwright_server_definition.json`):
|
302
|
+
|
303
|
+
```json
|
304
|
+
{
|
305
|
+
"mcpServers": {
|
306
|
+
"playwright": {
|
307
|
+
"url": "http://localhost:8931/sse",
|
308
|
+
"headers": {},
|
309
|
+
"comment": "Local Playwright MCP Server running on port 8931"
|
310
|
+
}
|
311
|
+
}
|
312
|
+
}
|
313
|
+
```
|
314
|
+
|
315
|
+
Load this configuration with:
|
316
|
+
|
317
|
+
```ruby
|
318
|
+
client = MCPClient.create_client(server_definition_file: 'path/to/definition.json')
|
319
|
+
```
|
320
|
+
|
321
|
+
The JSON format supports:
|
322
|
+
1. A single server object: `{ "type": "sse", "url": "..." }`
|
323
|
+
2. An array of server objects: `[{ "type": "stdio", ... }, { "type": "sse", ... }]`
|
324
|
+
3. An object with named servers under `mcpServers` key (as shown above)
|
325
|
+
|
326
|
+
Special configuration options:
|
327
|
+
- `comment` and `description` are reserved keys that are ignored during parsing and can be used for documentation
|
328
|
+
- Server type can be inferred from the presence of either `command` (for stdio) or `url` (for SSE)
|
329
|
+
- All string values in arrays (like `args`) are automatically converted to strings
|
330
|
+
|
331
|
+
## Key Features
|
332
|
+
|
333
|
+
### Client Features
|
334
|
+
|
335
|
+
- **Multiple transports** - Support for both stdio and SSE transports
|
336
|
+
- **Multiple servers** - Connect to multiple MCP servers simultaneously
|
337
|
+
- **Tool discovery** - Find tools by name or pattern
|
338
|
+
- **Atomic tool calls** - Simple API for invoking tools with parameters
|
339
|
+
- **Batch support** - Call multiple tools in a single operation
|
340
|
+
- **API conversions** - Built-in format conversion for OpenAI and Anthropic APIs
|
341
|
+
- **Thread safety** - Synchronized access for thread-safe operation
|
342
|
+
- **Server notifications** - Support for JSON-RPC notifications
|
343
|
+
- **Custom RPC methods** - Send any custom JSON-RPC method
|
344
|
+
- **Consistent error handling** - Rich error types for better exception handling
|
345
|
+
- **JSON configuration** - Support for server definition files in JSON format
|
209
346
|
|
210
347
|
### Server-Sent Events (SSE) Implementation
|
211
348
|
|
@@ -226,7 +363,7 @@ The SSE client implementation provides these key features:
|
|
226
363
|
|
227
364
|
## Requirements
|
228
365
|
|
229
|
-
- Ruby >= 2.
|
366
|
+
- Ruby >= 3.2.0
|
230
367
|
- No runtime dependencies
|
231
368
|
|
232
369
|
## Implementing an MCP Server
|
@@ -274,4 +411,4 @@ This gem is available as open source under the [MIT License](LICENSE).
|
|
274
411
|
## Contributing
|
275
412
|
|
276
413
|
Bug reports and pull requests are welcome on GitHub at
|
277
|
-
https://github.com/simonx1/ruby-mcp-client.
|
414
|
+
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,17 +163,16 @@ module MCPClient
|
|
163
163
|
end
|
164
164
|
end
|
165
165
|
|
166
|
-
# Ping the MCP server to check connectivity
|
167
|
-
# @param params [Hash] optional parameters for the ping request
|
166
|
+
# Ping the MCP server to check connectivity (zero-parameter heartbeat call)
|
168
167
|
# @param server_index [Integer, nil] optional index of a specific server to ping, nil for first available
|
169
168
|
# @return [Object] result from the ping request
|
170
169
|
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
171
|
-
def ping(
|
170
|
+
def ping(server_index: nil)
|
172
171
|
if server_index.nil?
|
173
172
|
# Ping first available server
|
174
173
|
raise MCPClient::Errors::ServerNotFound, 'No server available for ping' if @servers.empty?
|
175
174
|
|
176
|
-
@servers.first.ping
|
175
|
+
@servers.first.ping
|
177
176
|
else
|
178
177
|
# Ping specified server
|
179
178
|
if server_index >= @servers.length
|
@@ -181,12 +180,78 @@ module MCPClient
|
|
181
180
|
"Server at index #{server_index} not found"
|
182
181
|
end
|
183
182
|
|
184
|
-
@servers[server_index].ping
|
183
|
+
@servers[server_index].ping
|
185
184
|
end
|
186
185
|
end
|
187
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
|
+
|
188
207
|
private
|
189
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
|
+
|
190
255
|
# Validate parameters against tool JSON schema (checks required properties)
|
191
256
|
# @param tool [MCPClient::Tool] tool definition with schema
|
192
257
|
# @param parameters [Hash] parameters to validate
|
@@ -0,0 +1,203 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'logger'
|
5
|
+
|
6
|
+
module MCPClient
|
7
|
+
# Parses MCP server definition JSON files into configuration hashes
|
8
|
+
class ConfigParser
|
9
|
+
# Reserved JSON keys that shouldn't be included in final config
|
10
|
+
RESERVED_KEYS = %w[comment description].freeze
|
11
|
+
|
12
|
+
# @param file_path [String] path to JSON file containing 'mcpServers' definitions
|
13
|
+
# @param logger [Logger, nil] optional logger for warnings
|
14
|
+
def initialize(file_path, logger: nil)
|
15
|
+
@file_path = file_path
|
16
|
+
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Parse the JSON config and return a mapping of server names to clean config hashes
|
20
|
+
# @return [Hash<String, Hash>] server name => config hash with symbol keys
|
21
|
+
def parse
|
22
|
+
content = File.read(@file_path)
|
23
|
+
data = JSON.parse(content)
|
24
|
+
|
25
|
+
servers_data = extract_servers_data(data)
|
26
|
+
servers_data = filter_reserved_keys(servers_data)
|
27
|
+
|
28
|
+
result = {}
|
29
|
+
servers_data.each do |server_name, config|
|
30
|
+
next unless validate_server_config(config, server_name)
|
31
|
+
|
32
|
+
server_config = process_server_config(config, server_name)
|
33
|
+
result[server_name] = server_config if server_config
|
34
|
+
end
|
35
|
+
|
36
|
+
result
|
37
|
+
rescue Errno::ENOENT
|
38
|
+
raise Errno::ENOENT, "Server definition file not found: #{@file_path}"
|
39
|
+
rescue JSON::ParserError => e
|
40
|
+
raise JSON::ParserError, "Invalid JSON in #{@file_path}: #{e.message}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Extract server data from parsed JSON
|
44
|
+
# @param data [Object] parsed JSON data
|
45
|
+
# @return [Hash] normalized server data
|
46
|
+
def extract_servers_data(data)
|
47
|
+
if data.is_a?(Hash) && data.key?('mcpServers') && data['mcpServers'].is_a?(Hash)
|
48
|
+
data['mcpServers']
|
49
|
+
elsif data.is_a?(Array)
|
50
|
+
h = {}
|
51
|
+
data.each_with_index { |cfg, idx| h[idx.to_s] = cfg }
|
52
|
+
h
|
53
|
+
elsif data.is_a?(Hash)
|
54
|
+
{ '0' => data }
|
55
|
+
else
|
56
|
+
@logger.warn("Invalid root JSON structure in #{@file_path}: #{data.class}")
|
57
|
+
{}
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Validate server configuration is a hash
|
62
|
+
# @param config [Object] server configuration to validate
|
63
|
+
# @param server_name [String] name of the server
|
64
|
+
# @return [Boolean] true if valid, false otherwise
|
65
|
+
def validate_server_config(config, server_name)
|
66
|
+
return true if config.is_a?(Hash)
|
67
|
+
|
68
|
+
@logger.warn("Configuration for server '#{server_name}' is not an object; skipping.")
|
69
|
+
false
|
70
|
+
end
|
71
|
+
|
72
|
+
# Process a single server configuration
|
73
|
+
# @param config [Hash] server configuration to process
|
74
|
+
# @param server_name [String] name of the server
|
75
|
+
# @return [Hash, nil] processed configuration or nil if invalid
|
76
|
+
def process_server_config(config, server_name)
|
77
|
+
type = determine_server_type(config, server_name)
|
78
|
+
return nil unless type
|
79
|
+
|
80
|
+
clean = { type: type.to_s }
|
81
|
+
case type.to_s
|
82
|
+
when 'stdio'
|
83
|
+
parse_stdio_config(clean, config, server_name)
|
84
|
+
when 'sse'
|
85
|
+
return nil unless parse_sse_config(clean, config, server_name)
|
86
|
+
else
|
87
|
+
@logger.warn("Unrecognized type '#{type}' for server '#{server_name}'; skipping.")
|
88
|
+
return nil
|
89
|
+
end
|
90
|
+
|
91
|
+
clean
|
92
|
+
end
|
93
|
+
|
94
|
+
# Determine the type of server from its configuration
|
95
|
+
# @param config [Hash] server configuration
|
96
|
+
# @param server_name [String] name of the server for logging
|
97
|
+
# @return [String, nil] determined server type or nil if cannot be determined
|
98
|
+
def determine_server_type(config, server_name)
|
99
|
+
type = config['type']
|
100
|
+
return type if type
|
101
|
+
|
102
|
+
inferred_type = if config.key?('command') || config.key?('args') || config.key?('env')
|
103
|
+
'stdio'
|
104
|
+
elsif config.key?('url')
|
105
|
+
'sse'
|
106
|
+
end
|
107
|
+
|
108
|
+
if inferred_type
|
109
|
+
@logger.warn("'type' not specified for server '#{server_name}', inferring as '#{inferred_type}'.")
|
110
|
+
return inferred_type
|
111
|
+
end
|
112
|
+
|
113
|
+
@logger.warn("Could not determine type for server '#{server_name}' (missing 'command' or 'url'); skipping.")
|
114
|
+
nil
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
# Parse stdio-specific configuration
|
120
|
+
# @param clean [Hash] clean configuration hash to update
|
121
|
+
# @param config [Hash] raw configuration from JSON
|
122
|
+
# @param server_name [String] name of the server for error reporting
|
123
|
+
def parse_stdio_config(clean, config, server_name)
|
124
|
+
# Command is required
|
125
|
+
cmd = config['command']
|
126
|
+
unless cmd.is_a?(String)
|
127
|
+
@logger.warn("'command' for server '#{server_name}' is not a string; converting to string.")
|
128
|
+
cmd = cmd.to_s
|
129
|
+
end
|
130
|
+
|
131
|
+
# Args are optional
|
132
|
+
args = config['args']
|
133
|
+
if args.is_a?(Array)
|
134
|
+
args = args.map(&:to_s)
|
135
|
+
elsif args
|
136
|
+
@logger.warn("'args' for server '#{server_name}' is not an array; treating as single argument.")
|
137
|
+
args = [args.to_s]
|
138
|
+
else
|
139
|
+
args = []
|
140
|
+
end
|
141
|
+
|
142
|
+
# Environment variables are optional
|
143
|
+
env = config['env']
|
144
|
+
env = env.is_a?(Hash) ? env.transform_keys(&:to_s) : {}
|
145
|
+
|
146
|
+
# Update clean config
|
147
|
+
clean[:command] = cmd
|
148
|
+
clean[:args] = args
|
149
|
+
clean[:env] = env
|
150
|
+
end
|
151
|
+
|
152
|
+
# Parse SSE-specific configuration
|
153
|
+
# @param clean [Hash] clean configuration hash to update
|
154
|
+
# @param config [Hash] raw configuration from JSON
|
155
|
+
# @param server_name [String] name of the server for error reporting
|
156
|
+
# @return [Boolean] true if parsing succeeded, false if required elements are missing
|
157
|
+
def parse_sse_config(clean, config, server_name)
|
158
|
+
# URL is required
|
159
|
+
source = config['url']
|
160
|
+
unless source
|
161
|
+
@logger.warn("SSE server '#{server_name}' is missing required 'url' property; skipping.")
|
162
|
+
return false
|
163
|
+
end
|
164
|
+
|
165
|
+
unless source.is_a?(String)
|
166
|
+
@logger.warn("'url' for server '#{server_name}' is not a string; converting to string.")
|
167
|
+
source = source.to_s
|
168
|
+
end
|
169
|
+
|
170
|
+
# Headers are optional
|
171
|
+
headers = config['headers']
|
172
|
+
headers = headers.is_a?(Hash) ? headers.transform_keys(&:to_s) : {}
|
173
|
+
|
174
|
+
# Update clean config
|
175
|
+
clean[:url] = source
|
176
|
+
clean[:headers] = headers
|
177
|
+
true
|
178
|
+
end
|
179
|
+
|
180
|
+
# Filter out reserved keys from configuration objects
|
181
|
+
# @param data [Hash] configuration data
|
182
|
+
# @return [Hash] filtered configuration data
|
183
|
+
def filter_reserved_keys(data)
|
184
|
+
return data unless data.is_a?(Hash)
|
185
|
+
|
186
|
+
result = {}
|
187
|
+
data.each do |key, value|
|
188
|
+
# Skip reserved keys at server level
|
189
|
+
next if RESERVED_KEYS.include?(key)
|
190
|
+
|
191
|
+
# If value is a hash, recursively filter its keys too
|
192
|
+
if value.is_a?(Hash)
|
193
|
+
filtered_value = value.dup
|
194
|
+
RESERVED_KEYS.each { |reserved| filtered_value.delete(reserved) }
|
195
|
+
result[key] = filtered_value
|
196
|
+
else
|
197
|
+
result[key] = value
|
198
|
+
end
|
199
|
+
end
|
200
|
+
result
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
@@ -45,11 +45,10 @@ module MCPClient
|
|
45
45
|
raise NotImplementedError, 'Subclasses must implement rpc_notify'
|
46
46
|
end
|
47
47
|
|
48
|
-
# Ping the MCP server to check connectivity
|
49
|
-
# @param params [Hash] optional parameters for the ping request
|
48
|
+
# Ping the MCP server to check connectivity (zero-parameter heartbeat call)
|
50
49
|
# @return [Object] result from the ping request
|
51
|
-
def ping
|
52
|
-
rpc_request('ping'
|
50
|
+
def ping
|
51
|
+
rpc_request('ping')
|
53
52
|
end
|
54
53
|
|
55
54
|
# Register a callback to receive JSON-RPC notifications
|
@@ -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
|
@@ -33,10 +33,12 @@ module MCPClient
|
|
33
33
|
'Cache-Control' => 'no-cache',
|
34
34
|
'Connection' => 'keep-alive'
|
35
35
|
})
|
36
|
-
|
36
|
+
# HTTP client is managed via Faraday
|
37
37
|
@tools = nil
|
38
38
|
@read_timeout = read_timeout
|
39
|
-
|
39
|
+
|
40
|
+
# SSE-provided JSON-RPC endpoint path for POST requests
|
41
|
+
@rpc_endpoint = nil
|
40
42
|
@tools_data = nil
|
41
43
|
@request_id = 0
|
42
44
|
@sse_results = {}
|
@@ -132,19 +134,7 @@ module MCPClient
|
|
132
134
|
@mutex.synchronize do
|
133
135
|
return true if @connection_established
|
134
136
|
|
135
|
-
|
136
|
-
@http_client = Net::HTTP.new(uri.host, uri.port)
|
137
|
-
|
138
|
-
if uri.scheme == 'https'
|
139
|
-
@http_client.use_ssl = true
|
140
|
-
@http_client.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
141
|
-
end
|
142
|
-
|
143
|
-
@http_client.open_timeout = 10
|
144
|
-
@http_client.read_timeout = @read_timeout
|
145
|
-
@http_client.keep_alive_timeout = 60
|
146
|
-
|
147
|
-
@http_client.start
|
137
|
+
# Start SSE listener using Faraday HTTP client
|
148
138
|
start_sse_thread
|
149
139
|
|
150
140
|
timeout = 10
|
@@ -179,7 +169,6 @@ module MCPClient
|
|
179
169
|
end
|
180
170
|
|
181
171
|
@tools = nil
|
182
|
-
@session_id = nil
|
183
172
|
@connection_established = false
|
184
173
|
@sse_connected = false
|
185
174
|
end
|
@@ -204,31 +193,31 @@ module MCPClient
|
|
204
193
|
# @return [void]
|
205
194
|
def rpc_notify(method, params = {})
|
206
195
|
ensure_initialized
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
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
|
213
204
|
end
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
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}"
|
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
|
226
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}"
|
227
218
|
end
|
228
219
|
rescue StandardError => e
|
229
220
|
raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
|
230
|
-
ensure
|
231
|
-
rpc_http.finish if rpc_http&.started?
|
232
221
|
end
|
233
222
|
|
234
223
|
private
|
@@ -269,60 +258,31 @@ module MCPClient
|
|
269
258
|
return if @sse_thread&.alive?
|
270
259
|
|
271
260
|
@sse_thread = Thread.new do
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
sse_http = Net::HTTP.new(uri.host, uri.port)
|
276
|
-
|
277
|
-
if uri.scheme == 'https'
|
278
|
-
sse_http.use_ssl = true
|
279
|
-
sse_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
280
|
-
end
|
281
|
-
|
282
|
-
sse_http.open_timeout = 10
|
283
|
-
sse_http.read_timeout = @read_timeout
|
284
|
-
sse_http.keep_alive_timeout = 60
|
285
|
-
|
286
|
-
sse_http.start do |http|
|
287
|
-
request = Net::HTTP::Get.new(uri)
|
288
|
-
@headers.each { |k, v| request[k] = v }
|
289
|
-
|
290
|
-
http.request(request) do |response|
|
291
|
-
unless response.is_a?(Net::HTTPSuccess) && response['content-type']&.start_with?('text/event-stream')
|
292
|
-
@mutex.synchronize do
|
293
|
-
# Signal connection attempt completed (failed)
|
294
|
-
@connection_established = false
|
295
|
-
@connection_cv.broadcast
|
296
|
-
end
|
297
|
-
raise MCPClient::Errors::ServerError, 'Server response not OK or not text/event-stream'
|
298
|
-
end
|
261
|
+
uri = URI.parse(@base_url)
|
262
|
+
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
263
|
+
sse_path = uri.request_uri
|
299
264
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
end
|
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
|
306
270
|
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
end
|
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
|
319
|
-
nil
|
320
|
-
ensure
|
321
|
-
sse_http&.finish if sse_http&.started?
|
322
|
-
@mutex.synchronize do
|
323
|
-
@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)
|
324
276
|
end
|
325
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 }
|
326
286
|
end
|
327
287
|
end
|
328
288
|
|
@@ -352,14 +312,11 @@ module MCPClient
|
|
352
312
|
|
353
313
|
case event[:event]
|
354
314
|
when 'endpoint'
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
@
|
359
|
-
|
360
|
-
@connection_established = true
|
361
|
-
@connection_cv.broadcast
|
362
|
-
end
|
315
|
+
ep = event[:data]
|
316
|
+
@mutex.synchronize do
|
317
|
+
@rpc_endpoint = ep
|
318
|
+
@connection_established = true
|
319
|
+
@connection_cv.broadcast
|
363
320
|
end
|
364
321
|
when 'message'
|
365
322
|
begin
|
@@ -475,72 +432,56 @@ module MCPClient
|
|
475
432
|
def send_jsonrpc_request(request)
|
476
433
|
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
477
434
|
uri = URI.parse(@base_url)
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
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
|
483
443
|
end
|
484
444
|
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
end
|
498
|
-
|
499
|
-
uri = URI.parse(url)
|
500
|
-
http_request = Net::HTTP::Post.new(uri)
|
501
|
-
http_request.content_type = 'application/json'
|
502
|
-
http_request.body = request.to_json
|
503
|
-
|
504
|
-
headers = @mutex.synchronize { @headers.dup }
|
505
|
-
headers.except('Accept', 'Cache-Control')
|
506
|
-
.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}")
|
507
457
|
|
508
|
-
|
509
|
-
|
458
|
+
unless response.success?
|
459
|
+
raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
|
460
|
+
end
|
510
461
|
|
511
|
-
|
512
|
-
|
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)
|
513
471
|
end
|
472
|
+
return result if result
|
473
|
+
break if Time.now - start_time > timeout
|
514
474
|
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
|
525
|
-
end
|
526
|
-
break if result || (Time.now - start_time > timeout)
|
527
|
-
|
528
|
-
sleep 0.1
|
529
|
-
end
|
530
|
-
return result if result
|
531
|
-
|
532
|
-
raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
|
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}"
|
540
|
-
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}"
|
541
484
|
end
|
542
|
-
ensure
|
543
|
-
rpc_http.finish if rpc_http.started?
|
544
485
|
end
|
545
486
|
end
|
546
487
|
end
|
data/lib/mcp_client/version.rb
CHANGED
data/lib/mcp_client.rb
CHANGED
@@ -9,6 +9,7 @@ require_relative 'mcp_client/server_sse'
|
|
9
9
|
require_relative 'mcp_client/server_factory'
|
10
10
|
require_relative 'mcp_client/client'
|
11
11
|
require_relative 'mcp_client/version'
|
12
|
+
require_relative 'mcp_client/config_parser'
|
12
13
|
|
13
14
|
# Model Context Protocol (MCP) Client module
|
14
15
|
# Provides a standardized way for agents to communicate with external tools and services
|
@@ -16,9 +17,31 @@ require_relative 'mcp_client/version'
|
|
16
17
|
module MCPClient
|
17
18
|
# Create a new MCPClient client
|
18
19
|
# @param mcp_server_configs [Array<Hash>] configurations for MCP servers
|
20
|
+
# @param server_definition_file [String, nil] optional path to a JSON file defining server configurations
|
21
|
+
# The JSON may be a single server object or an array of server objects.
|
19
22
|
# @return [MCPClient::Client] new client instance
|
20
|
-
def self.create_client(mcp_server_configs: [])
|
21
|
-
|
23
|
+
def self.create_client(mcp_server_configs: [], server_definition_file: nil)
|
24
|
+
require 'json'
|
25
|
+
# Start with any explicit configs provided
|
26
|
+
configs = Array(mcp_server_configs)
|
27
|
+
# Load additional configs from a JSON file if specified
|
28
|
+
if server_definition_file
|
29
|
+
# Parse JSON definitions into clean config hashes
|
30
|
+
parser = MCPClient::ConfigParser.new(server_definition_file)
|
31
|
+
parsed = parser.parse
|
32
|
+
parsed.each_value do |cfg|
|
33
|
+
case cfg[:type].to_s
|
34
|
+
when 'stdio'
|
35
|
+
# Build command list with args
|
36
|
+
cmd_list = [cfg[:command]] + Array(cfg[:args])
|
37
|
+
configs << MCPClient.stdio_config(command: cmd_list)
|
38
|
+
when 'sse'
|
39
|
+
# Use 'url' from parsed config as 'base_url' for SSE config
|
40
|
+
configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {})
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
MCPClient::Client.new(mcp_server_configs: configs)
|
22
45
|
end
|
23
46
|
|
24
47
|
# Create a standard server configuration for stdio
|
@@ -35,13 +58,17 @@ module MCPClient
|
|
35
58
|
# @param base_url [String] base URL for the server
|
36
59
|
# @param headers [Hash] HTTP headers to include in requests
|
37
60
|
# @param read_timeout [Integer] read timeout in seconds (default: 30)
|
61
|
+
# @param retries [Integer] number of retry attempts (default: 0)
|
62
|
+
# @param retry_backoff [Integer] backoff delay in seconds (default: 1)
|
38
63
|
# @return [Hash] server configuration
|
39
|
-
def self.sse_config(base_url:, headers: {}, read_timeout: 30)
|
64
|
+
def self.sse_config(base_url:, headers: {}, read_timeout: 30, retries: 0, retry_backoff: 1)
|
40
65
|
{
|
41
66
|
type: 'sse',
|
42
67
|
base_url: base_url,
|
43
68
|
headers: headers,
|
44
|
-
read_timeout: read_timeout
|
69
|
+
read_timeout: read_timeout,
|
70
|
+
retries: retries,
|
71
|
+
retry_backoff: retry_backoff
|
45
72
|
}
|
46
73
|
end
|
47
74
|
end
|
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.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-26 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
|
@@ -78,6 +106,7 @@ files:
|
|
78
106
|
- README.md
|
79
107
|
- lib/mcp_client.rb
|
80
108
|
- lib/mcp_client/client.rb
|
109
|
+
- lib/mcp_client/config_parser.rb
|
81
110
|
- lib/mcp_client/errors.rb
|
82
111
|
- lib/mcp_client/server_base.rb
|
83
112
|
- lib/mcp_client/server_factory.rb
|
@@ -98,7 +127,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
98
127
|
requirements:
|
99
128
|
- - ">="
|
100
129
|
- !ruby/object:Gem::Version
|
101
|
-
version: 2.
|
130
|
+
version: 3.2.0
|
102
131
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
132
|
requirements:
|
104
133
|
- - ">="
|