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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87cf7d5701adff89363dd653d263189bded230a32232bbbb496d41b4092afdec
4
- data.tar.gz: 9a3561d2f97f0ef518cf75dfce82a8521140c9cdc381aaef3f3b3658265a7298
3
+ metadata.gz: 16280a305c2b9cd19ecc2a71b8f10d3805644015f34f7eaec28d5846f12fa1db
4
+ data.tar.gz: 48a791c3a88255c25078234819024dace993f7cfc564485f48680a8768b81238
5
5
  SHA512:
6
- metadata.gz: 361f1916a531f14ded3292e15ea4947b0e3715d71d2be71dd24ea198616a82c55e516379a59a455b6c14f9fb7b3b9a1d02bdd457fde0b975ece6320bbb6da23b
7
- data.tar.gz: b38270ec5a9ddce3a3689e6c8fb6e86efaa35453b6bcc8a590612d02fe1ef86040d62fd30fb95ff02640bb7b822bf8f473ae24fd571ad696069657f98a9d5c20
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({ echo: "hello" }) # With optional parameters
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 Implementation Features
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.7.0
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.
@@ -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 handling: clear tool cache on tools list change
35
- clear_cache if method == 'notifications/tools/list_changed'
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(params = {}, server_index: nil)
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(params)
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(params)
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(params = {})
52
- rpc_request('ping', params)
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, :session_id, :http_client, :server_info, :capabilities
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
- @http_client = nil
36
+ # HTTP client is managed via Faraday
37
37
  @tools = nil
38
38
  @read_timeout = read_timeout
39
- @session_id = nil
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
- uri = URI.parse(@base_url)
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
- url_base = @base_url.sub(%r{/sse/?$}, '')
208
- uri = URI.parse("#{url_base}/messages?sessionId=#{@session_id}")
209
- rpc_http = Net::HTTP.new(uri.host, uri.port)
210
- if uri.scheme == 'https'
211
- rpc_http.use_ssl = true
212
- rpc_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
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
- rpc_http.open_timeout = 10
215
- rpc_http.read_timeout = @read_timeout
216
- rpc_http.keep_alive_timeout = 60
217
- rpc_http.start do |http|
218
- http_req = Net::HTTP::Post.new(uri)
219
- http_req.content_type = 'application/json'
220
- http_req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
221
- headers = @headers.dup
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
- sse_http = nil
273
- begin
274
- uri = URI.parse(@base_url)
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
- @mutex.synchronize do
301
- # Signal connection established and SSE ready
302
- @sse_connected = true
303
- @connection_established = true
304
- @connection_cv.broadcast
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
- response.read_body do |chunk|
308
- @logger.debug("SSE chunk received: #{chunk.inspect}")
309
- process_sse_chunk(chunk.dup)
310
- end
311
- end
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
- if event[:data].include?('sessionId=')
356
- session_id = event[:data].split('sessionId=').last
357
-
358
- @mutex.synchronize do
359
- @session_id = session_id
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
- rpc_http = Net::HTTP.new(uri.host, uri.port)
479
-
480
- if uri.scheme == 'https'
481
- rpc_http.use_ssl = true
482
- rpc_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
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
- rpc_http.open_timeout = 10
486
- rpc_http.read_timeout = @read_timeout
487
- rpc_http.keep_alive_timeout = 60
488
-
489
- begin
490
- rpc_http.start do |http|
491
- session_id = @mutex.synchronize { @session_id }
492
-
493
- url = if session_id
494
- "#{@base_url.sub(%r{/sse/?$}, '')}/messages?sessionId=#{session_id}"
495
- else
496
- "#{@base_url.sub(%r{/sse/?$}, '')}/messages"
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
- response = http.request(http_request)
509
- @logger.debug("Received JSON-RPC response: #{response.code} #{response.body}")
458
+ unless response.success?
459
+ raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
460
+ end
510
461
 
511
- unless response.is_a?(Net::HTTPSuccess)
512
- raise MCPClient::Errors::ServerError, "Server returned error: #{response.code} #{response.message}"
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
- # If SSE transport is enabled, retrieve the result via the SSE channel
516
- if @use_sse
517
- request_id = request[:id]
518
- start_time = Time.now
519
- timeout = @read_timeout || 10
520
- result = nil
521
-
522
- loop do
523
- @mutex.synchronize do
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.4.1'
5
+ VERSION = '0.5.1'
6
6
  end
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
- MCPClient::Client.new(mcp_server_configs: mcp_server_configs)
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.1
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-24 00:00:00.000000000 Z
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.7.0
130
+ version: 3.2.0
102
131
  required_rubygems_version: !ruby/object:Gem::Requirement
103
132
  requirements:
104
133
  - - ">="