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.
- checksums.yaml +4 -4
- data/README.md +220 -1229
- data/lib/mcp_client/client.rb +189 -5
- data/lib/mcp_client/errors.rb +3 -0
- data/lib/mcp_client/http_transport_base.rb +7 -1
- data/lib/mcp_client/json_rpc_common.rb +7 -3
- data/lib/mcp_client/root.rb +63 -0
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +31 -1
- data/lib/mcp_client/server_sse.rb +130 -5
- data/lib/mcp_client/server_stdio.rb +140 -0
- data/lib/mcp_client/server_streamable_http.rb +131 -1
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +317 -4
- metadata +3 -2
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.
|
|
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
|
+
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
|