ruby-mcp-client 0.5.0 → 0.5.2
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 +71 -2
- data/lib/mcp_client/config_parser.rb +203 -0
- data/lib/mcp_client/server_sse.rb +255 -67
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +31 -4
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fbd3caf1e80f6c2caf625495072211b827ae08e3542fa33306537918d0b6eb5f
|
4
|
+
data.tar.gz: 1eea548ba10e7de3fec68a92d449e4552fde3c5920af045e2e828925dba2aad7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8eae783bd1e6a4db013a934e0686641befaec435fb288531ce85899efc0a66023d3d174dedffae21f75089f406f6368f286f13644701fbdee747492b795535b
|
7
|
+
data.tar.gz: 534cc5c88f8f3737c5fc3e684174980af74ac18398532e4b5698490b451b5fc5ff0831ca48639119445c71f4afa94ba3fca94ff81edfa03d1e3ba34a73c4f1b1
|
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
|
|
@@ -260,6 +272,62 @@ This client works with any MCP-compatible server, including:
|
|
260
272
|
- [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
|
261
273
|
- Custom servers implementing the MCP protocol
|
262
274
|
|
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/sample_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
|
+
|
263
331
|
## Key Features
|
264
332
|
|
265
333
|
### Client Features
|
@@ -274,6 +342,7 @@ This client works with any MCP-compatible server, including:
|
|
274
342
|
- **Server notifications** - Support for JSON-RPC notifications
|
275
343
|
- **Custom RPC methods** - Send any custom JSON-RPC method
|
276
344
|
- **Consistent error handling** - Rich error types for better exception handling
|
345
|
+
- **JSON configuration** - Support for server definition files in JSON format
|
277
346
|
|
278
347
|
### Server-Sent Events (SSE) Implementation
|
279
348
|
|
@@ -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
|
@@ -48,6 +48,7 @@ module MCPClient
|
|
48
48
|
@connection_established = false
|
49
49
|
@connection_cv = @mutex.new_cond
|
50
50
|
@initialized = false
|
51
|
+
@auth_error = nil
|
51
52
|
# Whether to use SSE transport; may disable if handshake fails
|
52
53
|
@use_sse = true
|
53
54
|
end
|
@@ -131,25 +132,30 @@ module MCPClient
|
|
131
132
|
# @return [Boolean] true if connection was successful
|
132
133
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
133
134
|
def connect
|
134
|
-
@mutex.synchronize
|
135
|
-
return true if @connection_established
|
135
|
+
return true if @mutex.synchronize { @connection_established }
|
136
136
|
|
137
|
-
|
137
|
+
begin
|
138
138
|
start_sse_thread
|
139
|
+
effective_timeout = [@read_timeout || 30, 30].min
|
140
|
+
wait_for_connection(timeout: effective_timeout)
|
141
|
+
true
|
142
|
+
rescue MCPClient::Errors::ConnectionError => e
|
143
|
+
cleanup
|
144
|
+
# Check for stored auth error first, as it's more specific
|
145
|
+
auth_error = @mutex.synchronize { @auth_error }
|
146
|
+
raise MCPClient::Errors::ConnectionError, auth_error if auth_error
|
147
|
+
|
148
|
+
raise MCPClient::Errors::ConnectionError, e.message if e.message.include?('Authorization failed')
|
149
|
+
|
150
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
151
|
+
rescue StandardError => e
|
152
|
+
cleanup
|
153
|
+
# Check for stored auth error
|
154
|
+
auth_error = @mutex.synchronize { @auth_error }
|
155
|
+
raise MCPClient::Errors::ConnectionError, auth_error if auth_error
|
139
156
|
|
140
|
-
|
141
|
-
success = @connection_cv.wait(timeout) { @connection_established }
|
142
|
-
|
143
|
-
unless success
|
144
|
-
cleanup
|
145
|
-
raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
|
146
|
-
end
|
147
|
-
|
148
|
-
@connection_established
|
157
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
149
158
|
end
|
150
|
-
rescue StandardError => e
|
151
|
-
cleanup
|
152
|
-
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
153
159
|
end
|
154
160
|
|
155
161
|
# Clean up the server connection
|
@@ -171,6 +177,7 @@ module MCPClient
|
|
171
177
|
@tools = nil
|
172
178
|
@connection_established = false
|
173
179
|
@sse_connected = false
|
180
|
+
# Don't clear auth error as we need it for reporting the correct error
|
174
181
|
end
|
175
182
|
end
|
176
183
|
|
@@ -222,6 +229,25 @@ module MCPClient
|
|
222
229
|
|
223
230
|
private
|
224
231
|
|
232
|
+
# Wait for SSE connection to be established with periodic checks
|
233
|
+
# @param timeout [Integer] Maximum time to wait in seconds
|
234
|
+
# @raise [MCPClient::Errors::ConnectionError] if timeout expires
|
235
|
+
def wait_for_connection(timeout:)
|
236
|
+
@mutex.synchronize do
|
237
|
+
deadline = Time.now + timeout
|
238
|
+
|
239
|
+
until @connection_established
|
240
|
+
remaining = [1, deadline - Time.now].min
|
241
|
+
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
242
|
+
end
|
243
|
+
|
244
|
+
unless @connection_established
|
245
|
+
cleanup
|
246
|
+
raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
225
251
|
# Ensure SSE initialization handshake has been performed
|
226
252
|
def ensure_initialized
|
227
253
|
return if @initialized
|
@@ -253,34 +279,85 @@ module MCPClient
|
|
253
279
|
@capabilities = result['capabilities'] if result.key?('capabilities')
|
254
280
|
end
|
255
281
|
|
282
|
+
# Set up the SSE connection
|
283
|
+
# @param uri [URI] The parsed base URL
|
284
|
+
# @return [Faraday::Connection] The configured Faraday connection
|
285
|
+
def setup_sse_connection(uri)
|
286
|
+
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
287
|
+
|
288
|
+
@sse_conn ||= Faraday.new(url: sse_base) do |f|
|
289
|
+
f.options.open_timeout = 10
|
290
|
+
f.options.timeout = nil
|
291
|
+
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
292
|
+
f.adapter Faraday.default_adapter
|
293
|
+
end
|
294
|
+
|
295
|
+
# Use response handling with status check
|
296
|
+
@sse_conn.builder.use Faraday::Response::RaiseError
|
297
|
+
@sse_conn
|
298
|
+
end
|
299
|
+
|
300
|
+
# Handle authorization errors from Faraday
|
301
|
+
# @param error [Faraday::Error] The authorization error
|
302
|
+
# @raise [MCPClient::Errors::ConnectionError] with appropriate message
|
303
|
+
def handle_sse_auth_error(error)
|
304
|
+
error_message = "Authorization failed: HTTP #{error.response[:status]}"
|
305
|
+
@logger.error(error_message)
|
306
|
+
|
307
|
+
@mutex.synchronize do
|
308
|
+
@auth_error = error_message
|
309
|
+
@connection_established = false
|
310
|
+
@connection_cv.broadcast
|
311
|
+
end
|
312
|
+
raise MCPClient::Errors::ConnectionError, error_message
|
313
|
+
end
|
314
|
+
|
315
|
+
# Reset connection state and signal waiting threads
|
316
|
+
def reset_connection_state
|
317
|
+
@mutex.synchronize do
|
318
|
+
@connection_established = false
|
319
|
+
@connection_cv.broadcast
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
256
323
|
# Start the SSE thread to listen for events
|
257
324
|
def start_sse_thread
|
258
325
|
return if @sse_thread&.alive?
|
259
326
|
|
260
327
|
@sse_thread = Thread.new do
|
261
328
|
uri = URI.parse(@base_url)
|
262
|
-
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
263
329
|
sse_path = uri.request_uri
|
330
|
+
conn = setup_sse_connection(uri)
|
264
331
|
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
332
|
+
# Reset connection state
|
333
|
+
@mutex.synchronize do
|
334
|
+
@sse_connected = false
|
335
|
+
@connection_established = false
|
269
336
|
end
|
270
337
|
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
338
|
+
begin
|
339
|
+
conn.get(sse_path) do |req|
|
340
|
+
@headers.each { |k, v| req.headers[k] = v }
|
341
|
+
|
342
|
+
req.options.on_data = proc do |chunk, _bytes|
|
343
|
+
process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
|
344
|
+
end
|
276
345
|
end
|
346
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
347
|
+
handle_sse_auth_error(e)
|
348
|
+
rescue Faraday::Error => e
|
349
|
+
@logger.error("Failed SSE connection: #{e.message}")
|
350
|
+
raise
|
277
351
|
end
|
278
|
-
rescue
|
279
|
-
#
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
352
|
+
rescue MCPClient::Errors::ConnectionError => e
|
353
|
+
# Re-raise connection errors to propagate them
|
354
|
+
# Signal connect method to stop waiting
|
355
|
+
reset_connection_state
|
356
|
+
raise e
|
357
|
+
rescue StandardError => e
|
358
|
+
@logger.error("SSE connection error: #{e.message}")
|
359
|
+
# Signal connect method to avoid deadlock
|
360
|
+
reset_connection_state
|
284
361
|
ensure
|
285
362
|
@mutex.synchronize { @sse_connected = false }
|
286
363
|
end
|
@@ -290,18 +367,126 @@ module MCPClient
|
|
290
367
|
# @param chunk [String] the chunk to process
|
291
368
|
def process_sse_chunk(chunk)
|
292
369
|
@logger.debug("Processing SSE chunk: #{chunk.inspect}")
|
293
|
-
local_buffer = nil
|
294
370
|
|
371
|
+
# Check for direct JSON error responses (which aren't proper SSE events)
|
372
|
+
if chunk.start_with?('{') && chunk.include?('"error"') &&
|
373
|
+
(chunk.include?('Unauthorized') || chunk.include?('authentication'))
|
374
|
+
begin
|
375
|
+
data = JSON.parse(chunk)
|
376
|
+
if data['error']
|
377
|
+
error_message = data['error']['message'] || 'Unknown server error'
|
378
|
+
|
379
|
+
@mutex.synchronize do
|
380
|
+
@auth_error = "Authorization failed: #{error_message}"
|
381
|
+
|
382
|
+
@connection_established = false
|
383
|
+
@connection_cv.broadcast
|
384
|
+
end
|
385
|
+
|
386
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
|
387
|
+
end
|
388
|
+
rescue JSON::ParserError
|
389
|
+
# Not valid JSON, process normally
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
event_buffers = nil
|
295
394
|
@mutex.synchronize do
|
296
395
|
@buffer += chunk
|
297
396
|
|
397
|
+
# Extract all complete events from the buffer
|
398
|
+
event_buffers = []
|
298
399
|
while (event_end = @buffer.index("\n\n"))
|
299
400
|
event_data = @buffer.slice!(0, event_end + 2)
|
300
|
-
|
401
|
+
event_buffers << event_data
|
301
402
|
end
|
302
403
|
end
|
303
404
|
|
304
|
-
|
405
|
+
# Process extracted events outside the mutex to avoid deadlocks
|
406
|
+
event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
|
407
|
+
end
|
408
|
+
|
409
|
+
# Handle SSE endpoint event
|
410
|
+
# @param data [String] The endpoint path
|
411
|
+
def handle_endpoint_event(data)
|
412
|
+
@mutex.synchronize do
|
413
|
+
@rpc_endpoint = data
|
414
|
+
@sse_connected = true
|
415
|
+
@connection_established = true
|
416
|
+
@connection_cv.broadcast
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
# Check if the error represents an authorization error
|
421
|
+
# @param error_message [String] The error message from the server
|
422
|
+
# @param error_code [Integer, nil] The error code if available
|
423
|
+
# @return [Boolean] True if it's an authorization error
|
424
|
+
def authorization_error?(error_message, error_code)
|
425
|
+
return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
|
426
|
+
return true if [401, -32_000].include?(error_code)
|
427
|
+
|
428
|
+
false
|
429
|
+
end
|
430
|
+
|
431
|
+
# Handle authorization error in SSE message
|
432
|
+
# @param error_message [String] The error message from the server
|
433
|
+
def handle_sse_auth_error_message(error_message)
|
434
|
+
@mutex.synchronize do
|
435
|
+
@auth_error = "Authorization failed: #{error_message}"
|
436
|
+
@connection_established = false
|
437
|
+
@connection_cv.broadcast
|
438
|
+
end
|
439
|
+
|
440
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
|
441
|
+
end
|
442
|
+
|
443
|
+
# Process error messages in SSE responses
|
444
|
+
# @param data [Hash] The parsed SSE message data
|
445
|
+
def process_error_in_message(data)
|
446
|
+
return unless data['error']
|
447
|
+
|
448
|
+
error_message = data['error']['message'] || 'Unknown server error'
|
449
|
+
error_code = data['error']['code']
|
450
|
+
|
451
|
+
# Handle unauthorized errors (close connection immediately)
|
452
|
+
handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
|
453
|
+
|
454
|
+
@logger.error("Server error: #{error_message}")
|
455
|
+
true # Error was processed
|
456
|
+
end
|
457
|
+
|
458
|
+
# Process JSON-RPC notifications
|
459
|
+
# @param data [Hash] The parsed SSE message data
|
460
|
+
# @return [Boolean] True if a notification was processed
|
461
|
+
def process_notification(data)
|
462
|
+
return false unless data['method'] && !data.key?('id')
|
463
|
+
|
464
|
+
@notification_callback&.call(data['method'], data['params'])
|
465
|
+
true
|
466
|
+
end
|
467
|
+
|
468
|
+
# Process JSON-RPC responses
|
469
|
+
# @param data [Hash] The parsed SSE message data
|
470
|
+
# @return [Boolean] True if a response was processed
|
471
|
+
def process_response(data)
|
472
|
+
return false unless data['id']
|
473
|
+
|
474
|
+
@mutex.synchronize do
|
475
|
+
# Store tools data if present
|
476
|
+
@tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
|
477
|
+
|
478
|
+
# Store response for the waiting request
|
479
|
+
if data['error']
|
480
|
+
@sse_results[data['id']] = {
|
481
|
+
'isError' => true,
|
482
|
+
'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
|
483
|
+
}
|
484
|
+
elsif data['result']
|
485
|
+
@sse_results[data['id']] = data['result']
|
486
|
+
end
|
487
|
+
end
|
488
|
+
|
489
|
+
true
|
305
490
|
end
|
306
491
|
|
307
492
|
# Parse and handle an SSE event
|
@@ -312,38 +497,35 @@ module MCPClient
|
|
312
497
|
|
313
498
|
case event[:event]
|
314
499
|
when 'endpoint'
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
@connection_established = true
|
319
|
-
@connection_cv.broadcast
|
320
|
-
end
|
500
|
+
handle_endpoint_event(event[:data])
|
501
|
+
when 'ping'
|
502
|
+
# Received ping event, no action needed
|
321
503
|
when 'message'
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
if data['method'] && !data.key?('id')
|
326
|
-
@notification_callback&.call(data['method'], data['params'])
|
327
|
-
return
|
328
|
-
end
|
504
|
+
handle_message_event(event)
|
505
|
+
end
|
506
|
+
end
|
329
507
|
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
508
|
+
# Handle a message event from SSE
|
509
|
+
# @param event [Hash] The parsed SSE event
|
510
|
+
def handle_message_event(event)
|
511
|
+
return if event[:data].empty?
|
512
|
+
|
513
|
+
begin
|
514
|
+
data = JSON.parse(event[:data])
|
515
|
+
|
516
|
+
# Process the message in order of precedence
|
517
|
+
return if process_error_in_message(data)
|
518
|
+
|
519
|
+
return if process_notification(data)
|
520
|
+
|
521
|
+
process_response(data)
|
522
|
+
rescue MCPClient::Errors::ConnectionError
|
523
|
+
# Re-raise connection errors to propagate to the calling code
|
524
|
+
raise
|
525
|
+
rescue JSON::ParserError => e
|
526
|
+
@logger.warn("Failed to parse JSON from event data: #{e.message}")
|
527
|
+
rescue StandardError => e
|
528
|
+
@logger.error("Error processing SSE event: #{e.message}")
|
347
529
|
end
|
348
530
|
end
|
349
531
|
|
@@ -351,14 +533,19 @@ module MCPClient
|
|
351
533
|
# @param event_data [String] the event data to parse
|
352
534
|
# @return [Hash, nil] the parsed event, or nil if the event is invalid
|
353
535
|
def parse_sse_event(event_data)
|
354
|
-
@logger.debug("Parsing SSE event data: #{event_data.inspect}")
|
355
536
|
event = { event: 'message', data: '', id: nil }
|
356
537
|
data_lines = []
|
538
|
+
has_content = false
|
357
539
|
|
358
540
|
event_data.each_line do |line|
|
359
541
|
line = line.chomp
|
360
542
|
next if line.empty?
|
361
543
|
|
544
|
+
# Skip SSE comments (lines starting with colon)
|
545
|
+
next if line.start_with?(':')
|
546
|
+
|
547
|
+
has_content = true
|
548
|
+
|
362
549
|
if line.start_with?('event:')
|
363
550
|
event[:event] = line[6..].strip
|
364
551
|
elsif line.start_with?('data:')
|
@@ -369,8 +556,9 @@ module MCPClient
|
|
369
556
|
end
|
370
557
|
|
371
558
|
event[:data] = data_lines.join("\n")
|
372
|
-
|
373
|
-
event
|
559
|
+
|
560
|
+
# Return the event even if data is empty as long as we had non-comment content
|
561
|
+
has_content ? event : nil
|
374
562
|
end
|
375
563
|
|
376
564
|
# Request the tools list using JSON-RPC
|
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,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.5.
|
4
|
+
version: 0.5.2
|
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-
|
11
|
+
date: 2025-05-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|
@@ -106,6 +106,7 @@ files:
|
|
106
106
|
- README.md
|
107
107
|
- lib/mcp_client.rb
|
108
108
|
- lib/mcp_client/client.rb
|
109
|
+
- lib/mcp_client/config_parser.rb
|
109
110
|
- lib/mcp_client/errors.rb
|
110
111
|
- lib/mcp_client/server_base.rb
|
111
112
|
- lib/mcp_client/server_factory.rb
|