ruby-mcp-client 0.9.0 → 0.9.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.
data/lib/mcp_client.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'mcp_client/prompt'
7
7
  require_relative 'mcp_client/resource'
8
8
  require_relative 'mcp_client/resource_template'
9
9
  require_relative 'mcp_client/resource_content'
10
+ require_relative 'mcp_client/root'
10
11
  require_relative 'mcp_client/server_base'
11
12
  require_relative 'mcp_client/server_stdio'
12
13
  require_relative 'mcp_client/server_sse'
@@ -23,6 +24,312 @@ require_relative 'mcp_client/oauth_client'
23
24
  # Provides a standardized way for agents to communicate with external tools and services
24
25
  # through a protocol-based approach
25
26
  module MCPClient
27
+ # Simplified connection API - auto-detects transport and returns connected client
28
+ #
29
+ # @param target [String, Array<String>] URL(s) or command for connection
30
+ # - URLs ending in /sse -> SSE transport
31
+ # - URLs ending in /mcp -> Streamable HTTP transport
32
+ # - stdio://command or Array commands -> stdio transport
33
+ # - Commands starting with npx, node, python, ruby, etc. -> stdio transport
34
+ # - Other HTTP URLs -> Try Streamable HTTP, fallback to SSE, then HTTP
35
+ # Accepts keyword arguments for connection options:
36
+ # - headers [Hash] HTTP headers for remote transports
37
+ # - read_timeout [Integer] Request timeout in seconds (default: 30)
38
+ # - retries [Integer] Retry attempts
39
+ # - retry_backoff [Numeric] Backoff delay (default: 1)
40
+ # - name [String] Optional server name
41
+ # - logger [Logger] Optional logger
42
+ # - env [Hash] Environment variables for stdio
43
+ # - ping [Integer] Ping interval for SSE (default: 10)
44
+ # - endpoint [String] JSON-RPC endpoint path (default: '/rpc')
45
+ # - transport [Symbol] Force transport type (:stdio, :sse, :http, :streamable_http)
46
+ # - sampling_handler [Proc] Handler for sampling requests
47
+ # @yield [Faraday::Connection] Optional block for Faraday customization
48
+ # @return [MCPClient::Client] Connected client ready to use
49
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
50
+ # @raise [MCPClient::Errors::TransportDetectionError] if transport cannot be determined
51
+ #
52
+ # @example Connect to SSE server
53
+ # client = MCPClient.connect('http://localhost:8000/sse')
54
+ #
55
+ # @example Connect to Streamable HTTP server
56
+ # client = MCPClient.connect('http://localhost:8000/mcp')
57
+ #
58
+ # @example Connect with options
59
+ # client = MCPClient.connect('http://api.example.com/mcp',
60
+ # headers: { 'Authorization' => 'Bearer token' },
61
+ # read_timeout: 60
62
+ # )
63
+ #
64
+ # @example Connect to stdio server
65
+ # client = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem /home')
66
+ # # or with Array
67
+ # client = MCPClient.connect(['npx', '-y', '@modelcontextprotocol/server-filesystem', '/home'])
68
+ #
69
+ # @example Connect to multiple servers
70
+ # client = MCPClient.connect(['http://server1/mcp', 'http://server2/sse'])
71
+ #
72
+ # @example Force transport type
73
+ # client = MCPClient.connect('http://custom-server.com', transport: :streamable_http)
74
+ #
75
+ # @example With Faraday customization
76
+ # client = MCPClient.connect('https://internal.server.com/mcp') do |faraday|
77
+ # faraday.ssl.cert_store = custom_cert_store
78
+ # end
79
+ def self.connect(target, **, &)
80
+ # Handle array targets: either a single stdio command or multiple server URLs
81
+ if target.is_a?(Array)
82
+ # Check if it's a stdio command array (elements are command parts, not URLs)
83
+ if stdio_command_array?(target)
84
+ connect_single(target, **, &)
85
+ else
86
+ # It's an array of server URLs/commands
87
+ connect_multiple(target, **, &)
88
+ end
89
+ else
90
+ connect_single(target, **, &)
91
+ end
92
+ end
93
+
94
+ class << self
95
+ private
96
+
97
+ # Connect to a single server
98
+ def connect_single(target, **options, &)
99
+ transport = options[:transport]&.to_sym || detect_transport(target)
100
+
101
+ case transport
102
+ when :stdio
103
+ connect_stdio(target, **options)
104
+ when :sse
105
+ connect_sse(target, **options)
106
+ when :http
107
+ connect_http(target, **options, &)
108
+ when :streamable_http
109
+ connect_streamable_http(target, **options, &)
110
+ when :auto
111
+ connect_with_fallback(target, **options, &)
112
+ else
113
+ raise Errors::TransportDetectionError, "Unknown transport: #{transport}"
114
+ end
115
+ end
116
+
117
+ # Connect to multiple servers
118
+ def connect_multiple(targets, **options, &faraday_config)
119
+ configs = targets.map.with_index do |t, idx|
120
+ server_name = options[:name] ? "#{options[:name]}_#{idx}" : "server_#{idx}"
121
+ build_config_for_target(t, **options.merge(name: server_name), &faraday_config)
122
+ end
123
+
124
+ client = Client.new(
125
+ mcp_server_configs: configs,
126
+ logger: options[:logger],
127
+ sampling_handler: options[:sampling_handler]
128
+ )
129
+
130
+ # Connect all servers
131
+ client.servers.each(&:connect)
132
+ client
133
+ end
134
+
135
+ # Connect via stdio transport
136
+ def connect_stdio(target, **options)
137
+ command = parse_stdio_command(target)
138
+ config = stdio_config(command: command, **extract_stdio_options(options))
139
+ create_and_connect_client(config, options)
140
+ end
141
+
142
+ # Connect via SSE transport
143
+ def connect_sse(url, **options)
144
+ config = sse_config(base_url: url.to_s, **extract_sse_options(options))
145
+ create_and_connect_client(config, options)
146
+ end
147
+
148
+ # Connect via HTTP transport
149
+ def connect_http(url, **options, &)
150
+ config = http_config(base_url: url.to_s, **extract_http_options(options), &)
151
+ create_and_connect_client(config, options)
152
+ end
153
+
154
+ # Connect via Streamable HTTP transport
155
+ def connect_streamable_http(url, **options, &)
156
+ config = streamable_http_config(base_url: url.to_s, **extract_http_options(options), &)
157
+ create_and_connect_client(config, options)
158
+ end
159
+
160
+ # Create client and connect to server
161
+ def create_and_connect_client(config, options)
162
+ client = Client.new(
163
+ mcp_server_configs: [config],
164
+ logger: options[:logger],
165
+ sampling_handler: options[:sampling_handler]
166
+ )
167
+ client.servers.first.connect
168
+ client
169
+ end
170
+
171
+ # Try transports in order until one succeeds
172
+ def connect_with_fallback(url, **options, &)
173
+ require 'logger'
174
+ logger = options[:logger] || Logger.new($stderr, level: Logger::WARN)
175
+ errors = []
176
+
177
+ # Try Streamable HTTP first (most modern)
178
+ begin
179
+ logger.debug("MCPClient.connect: Attempting Streamable HTTP connection to #{url}")
180
+ return connect_streamable_http(url, **options, &)
181
+ rescue Errors::ConnectionError, Errors::TransportError => e
182
+ errors << "Streamable HTTP: #{e.message}"
183
+ logger.debug("MCPClient.connect: Streamable HTTP failed: #{e.message}")
184
+ end
185
+
186
+ # Try SSE second
187
+ begin
188
+ logger.debug("MCPClient.connect: Attempting SSE connection to #{url}")
189
+ return connect_sse(url, **options)
190
+ rescue Errors::ConnectionError, Errors::TransportError => e
191
+ errors << "SSE: #{e.message}"
192
+ logger.debug("MCPClient.connect: SSE failed: #{e.message}")
193
+ end
194
+
195
+ # Try plain HTTP last
196
+ begin
197
+ logger.debug("MCPClient.connect: Attempting HTTP connection to #{url}")
198
+ return connect_http(url, **options, &)
199
+ rescue Errors::ConnectionError, Errors::TransportError => e
200
+ errors << "HTTP: #{e.message}"
201
+ logger.debug("MCPClient.connect: HTTP failed: #{e.message}")
202
+ end
203
+
204
+ raise Errors::ConnectionError,
205
+ "Failed to connect to #{url}. Tried all transports:\n #{errors.join("\n ")}"
206
+ end
207
+
208
+ # Detect transport type from target
209
+ def detect_transport(target)
210
+ return :stdio if target.is_a?(Array) && stdio_command_array?(target)
211
+ return :stdio if stdio_target?(target)
212
+
213
+ uri = begin
214
+ URI.parse(target.to_s)
215
+ rescue URI::InvalidURIError
216
+ raise Errors::TransportDetectionError, "Invalid URL: #{target}"
217
+ end
218
+
219
+ unless http_url?(uri)
220
+ raise Errors::TransportDetectionError,
221
+ "Cannot detect transport for non-HTTP URL: #{target}. " \
222
+ 'Use transport: option to specify explicitly.'
223
+ end
224
+
225
+ path = uri.path.to_s.downcase
226
+ return :sse if path.end_with?('/sse')
227
+ return :streamable_http if path.end_with?('/mcp')
228
+
229
+ # Ambiguous HTTP URL - use fallback strategy
230
+ :auto
231
+ end
232
+
233
+ # Check if target is a stdio command (string form)
234
+ def stdio_target?(target)
235
+ return false if target.is_a?(Array) # Arrays handled separately by stdio_command_array?
236
+
237
+ target_str = target.to_s
238
+ return true if target_str.start_with?('stdio://')
239
+ return true if target_str.match?(/^(npx|node|python|python3|ruby|php|java|cargo|go run)\b/)
240
+
241
+ false
242
+ end
243
+
244
+ # Check if an array represents a single stdio command (vs multiple server URLs)
245
+ # A stdio command array has elements that are command parts, not URLs
246
+ def stdio_command_array?(arr)
247
+ return false unless arr.is_a?(Array) && arr.any?
248
+
249
+ first = arr.first.to_s
250
+ # If the first element looks like a URL, it's not a stdio command array
251
+ return false if first.match?(%r{^https?://})
252
+ return false if first.start_with?('stdio://')
253
+
254
+ # If the first element is a known command executable, it's a stdio command array
255
+ return true if first.match?(/^(npx|node|python|python3|ruby|php|java|cargo|go)$/)
256
+
257
+ # If none of the elements look like URLs, assume it's a command array
258
+ arr.none? { |el| el.to_s.match?(%r{^https?://}) }
259
+ end
260
+
261
+ # Check if URI is HTTP/HTTPS
262
+ def http_url?(uri)
263
+ %w[http https].include?(uri.scheme&.downcase)
264
+ end
265
+
266
+ # Parse stdio command from various formats
267
+ def parse_stdio_command(target)
268
+ return target if target.is_a?(Array)
269
+
270
+ target_str = target.to_s
271
+ if target_str.start_with?('stdio://')
272
+ target_str.sub('stdio://', '')
273
+ else
274
+ target_str
275
+ end
276
+ end
277
+
278
+ # Extract common options shared by all transports
279
+ def extract_common_options(options)
280
+ {
281
+ name: options[:name],
282
+ logger: options[:logger],
283
+ read_timeout: options[:read_timeout],
284
+ retries: options[:retries],
285
+ retry_backoff: options[:retry_backoff]
286
+ }.compact
287
+ end
288
+
289
+ # Extract HTTP transport specific options
290
+ def extract_http_options(options)
291
+ extract_common_options(options).merge({
292
+ headers: options[:headers] || {},
293
+ endpoint: options[:endpoint]
294
+ }.compact)
295
+ end
296
+
297
+ # Extract SSE transport specific options
298
+ def extract_sse_options(options)
299
+ extract_common_options(options).merge({
300
+ headers: options[:headers] || {},
301
+ ping: options[:ping]
302
+ }.compact)
303
+ end
304
+
305
+ # Extract stdio transport specific options
306
+ def extract_stdio_options(options)
307
+ extract_common_options(options).merge({
308
+ env: options[:env] || {}
309
+ }.compact)
310
+ end
311
+
312
+ # Build config hash for a target
313
+ def build_config_for_target(target, **options, &)
314
+ transport = options[:transport]&.to_sym || detect_transport(target)
315
+
316
+ case transport
317
+ when :stdio
318
+ command = parse_stdio_command(target)
319
+ stdio_config(command: command, **extract_stdio_options(options))
320
+ when :sse
321
+ sse_config(base_url: target.to_s, **extract_sse_options(options))
322
+ when :http
323
+ http_config(base_url: target.to_s, **extract_http_options(options), &)
324
+ when :streamable_http, :auto
325
+ # For multi-server, default to streamable_http without fallback
326
+ streamable_http_config(base_url: target.to_s, **extract_http_options(options), &)
327
+ else
328
+ raise Errors::TransportDetectionError, "Unknown transport: #{transport}"
329
+ end
330
+ end
331
+ end
332
+
26
333
  # Create a new MCPClient client
27
334
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
28
335
  # @param server_definition_file [String, nil] optional path to a JSON file defining server configurations
@@ -112,9 +419,11 @@ module MCPClient
112
419
  # @param retry_backoff [Integer] backoff delay in seconds (default: 1)
113
420
  # @param name [String, nil] optional name for this server
114
421
  # @param logger [Logger, nil] optional logger for server operations
422
+ # @yieldparam faraday [Faraday::Connection] the configured connection instance for additional customization
423
+ # (e.g., SSL settings, custom middleware). The block is called after default configuration is applied.
115
424
  # @return [Hash] server configuration
116
425
  def self.http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3, retry_backoff: 1,
117
- name: nil, logger: nil)
426
+ name: nil, logger: nil, &faraday_config)
118
427
  {
119
428
  type: 'http',
120
429
  base_url: base_url,
@@ -124,7 +433,8 @@ module MCPClient
124
433
  retries: retries,
125
434
  retry_backoff: retry_backoff,
126
435
  name: name,
127
- logger: logger
436
+ logger: logger,
437
+ faraday_config: faraday_config
128
438
  }
129
439
  end
130
440
 
@@ -138,9 +448,11 @@ module MCPClient
138
448
  # @param retry_backoff [Integer] Backoff delay in seconds (default: 1)
139
449
  # @param name [String, nil] Optional name for this server
140
450
  # @param logger [Logger, nil] Optional logger for server operations
451
+ # @yieldparam faraday [Faraday::Connection] the configured connection instance for additional customization
452
+ # (e.g., SSL settings, custom middleware). The block is called after default configuration is applied.
141
453
  # @return [Hash] server configuration
142
454
  def self.streamable_http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3,
143
- retry_backoff: 1, name: nil, logger: nil)
455
+ retry_backoff: 1, name: nil, logger: nil, &faraday_config)
144
456
  {
145
457
  type: 'streamable_http',
146
458
  base_url: base_url,
@@ -150,7 +462,8 @@ module MCPClient
150
462
  retries: retries,
151
463
  retry_backoff: retry_backoff,
152
464
  name: name,
153
- logger: logger
465
+ logger: logger,
466
+ faraday_config: faraday_config
154
467
  }
155
468
  end
156
469
  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.9.0
4
+ version: 0.9.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-11-05 00:00:00.000000000 Z
11
+ date: 2025-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -132,6 +132,7 @@ files:
132
132
  - lib/mcp_client/resource.rb
133
133
  - lib/mcp_client/resource_content.rb
134
134
  - lib/mcp_client/resource_template.rb
135
+ - lib/mcp_client/root.rb
135
136
  - lib/mcp_client/server_base.rb
136
137
  - lib/mcp_client/server_factory.rb
137
138
  - lib/mcp_client/server_http.rb