ruby-mcp-client 0.9.0 → 1.0.0

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.
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Represents an MCP Task for long-running operations with progress tracking
5
+ # Tasks follow the MCP 2025-11-25 specification for structured task management
6
+ #
7
+ # Task states: pending, running, completed, failed, cancelled
8
+ class Task
9
+ # Valid task states
10
+ VALID_STATES = %w[pending running completed failed cancelled].freeze
11
+
12
+ attr_reader :id, :state, :progress_token, :progress, :total, :message, :result, :server
13
+
14
+ # Create a new Task
15
+ # @param id [String] unique task identifier
16
+ # @param state [String] task state (pending, running, completed, failed, cancelled)
17
+ # @param progress_token [String, nil] optional token for tracking progress
18
+ # @param progress [Integer, nil] current progress value
19
+ # @param total [Integer, nil] total progress value
20
+ # @param message [String, nil] human-readable status message
21
+ # @param result [Object, nil] task result (when completed)
22
+ # @param server [MCPClient::ServerBase, nil] the server this task belongs to
23
+ def initialize(id:, state: 'pending', progress_token: nil, progress: nil, total: nil,
24
+ message: nil, result: nil, server: nil)
25
+ validate_state!(state)
26
+ @id = id
27
+ @state = state
28
+ @progress_token = progress_token
29
+ @progress = progress
30
+ @total = total
31
+ @message = message
32
+ @result = result
33
+ @server = server
34
+ end
35
+
36
+ # Create a Task from a JSON hash
37
+ # @param json [Hash] the JSON hash with task fields
38
+ # @param server [MCPClient::ServerBase, nil] optional server reference
39
+ # @return [Task]
40
+ def self.from_json(json, server: nil)
41
+ new(
42
+ id: json['id'] || json[:id],
43
+ state: json['state'] || json[:state] || 'pending',
44
+ progress_token: json['progressToken'] || json[:progressToken] || json[:progress_token],
45
+ progress: json['progress'] || json[:progress],
46
+ total: json['total'] || json[:total],
47
+ message: json['message'] || json[:message],
48
+ result: json.key?('result') ? json['result'] : json[:result],
49
+ server: server
50
+ )
51
+ end
52
+
53
+ # Convert to JSON-serializable hash
54
+ # @return [Hash]
55
+ def to_h
56
+ result = { 'id' => @id, 'state' => @state }
57
+ result['progressToken'] = @progress_token if @progress_token
58
+ result['progress'] = @progress if @progress
59
+ result['total'] = @total if @total
60
+ result['message'] = @message if @message
61
+ result['result'] = @result unless @result.nil?
62
+ result
63
+ end
64
+
65
+ # Convert to JSON string
66
+ # @return [String]
67
+ def to_json(*)
68
+ to_h.to_json(*)
69
+ end
70
+
71
+ # Check if task is in a terminal state
72
+ # @return [Boolean]
73
+ def terminal?
74
+ %w[completed failed cancelled].include?(@state)
75
+ end
76
+
77
+ # Check if task is still active (pending or running)
78
+ # @return [Boolean]
79
+ def active?
80
+ %w[pending running].include?(@state)
81
+ end
82
+
83
+ # Calculate progress percentage
84
+ # @return [Float, nil] percentage (0.0-100.0) or nil if progress info unavailable
85
+ def progress_percentage
86
+ return nil unless @progress && @total&.positive?
87
+
88
+ (@progress.to_f / @total * 100).round(2)
89
+ end
90
+
91
+ # Check equality
92
+ def ==(other)
93
+ return false unless other.is_a?(Task)
94
+
95
+ id == other.id && state == other.state
96
+ end
97
+
98
+ alias eql? ==
99
+
100
+ def hash
101
+ [id, state].hash
102
+ end
103
+
104
+ # String representation
105
+ def to_s
106
+ parts = ["Task[#{@id}]: #{@state}"]
107
+ parts << "(#{@progress}/#{@total})" if @progress && @total
108
+ parts << "- #{@message}" if @message
109
+ parts.join(' ')
110
+ end
111
+
112
+ def inspect
113
+ "#<MCPClient::Task id=#{@id.inspect} state=#{@state.inspect}>"
114
+ end
115
+
116
+ private
117
+
118
+ # Validate task state
119
+ # @param state [String] the state to validate
120
+ # @raise [ArgumentError] if the state is not valid
121
+ def validate_state!(state)
122
+ return if VALID_STATES.include?(state)
123
+
124
+ raise ArgumentError, "Invalid task state: #{state.inspect}. Must be one of: #{VALID_STATES.join(', ')}"
125
+ end
126
+ end
127
+ end
@@ -5,6 +5,8 @@ module MCPClient
5
5
  class Tool
6
6
  # @!attribute [r] name
7
7
  # @return [String] the name of the tool
8
+ # @!attribute [r] title
9
+ # @return [String, nil] optional human-readable name of the tool for display purposes
8
10
  # @!attribute [r] description
9
11
  # @return [String] the description of the tool
10
12
  # @!attribute [r] schema
@@ -15,17 +17,19 @@ module MCPClient
15
17
  # @return [Hash, nil] optional annotations describing tool behavior (e.g., readOnly, destructive)
16
18
  # @!attribute [r] server
17
19
  # @return [MCPClient::ServerBase, nil] the server this tool belongs to
18
- attr_reader :name, :description, :schema, :output_schema, :annotations, :server
20
+ attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server
19
21
 
20
22
  # Initialize a new Tool
21
23
  # @param name [String] the name of the tool
22
24
  # @param description [String] the description of the tool
23
25
  # @param schema [Hash] the JSON schema for the tool inputs
26
+ # @param title [String, nil] optional human-readable name of the tool for display purposes
24
27
  # @param output_schema [Hash, nil] optional JSON schema for structured tool outputs (MCP 2025-06-18)
25
28
  # @param annotations [Hash, nil] optional annotations describing tool behavior
26
29
  # @param server [MCPClient::ServerBase, nil] the server this tool belongs to
27
- def initialize(name:, description:, schema:, output_schema: nil, annotations: nil, server: nil)
30
+ def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil)
28
31
  @name = name
32
+ @title = title
29
33
  @description = description
30
34
  @schema = schema
31
35
  @output_schema = output_schema
@@ -43,10 +47,12 @@ module MCPClient
43
47
  schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
44
48
  output_schema = data['outputSchema'] || data[:outputSchema]
45
49
  annotations = data['annotations'] || data[:annotations]
50
+ title = data['title'] || data[:title]
46
51
  new(
47
52
  name: data['name'] || data[:name],
48
53
  description: data['description'] || data[:description],
49
54
  schema: schema,
55
+ title: title,
50
56
  output_schema: output_schema,
51
57
  annotations: annotations,
52
58
  server: server
@@ -67,12 +73,12 @@ module MCPClient
67
73
  end
68
74
 
69
75
  # Convert tool to Anthropic Claude tool specification format
70
- # @return [Hash] Anthropic Claude tool specification
76
+ # @return [Hash] Anthropic Claude tool specification with cleaned schema
71
77
  def to_anthropic_tool
72
78
  {
73
79
  name: @name,
74
80
  description: @description,
75
- input_schema: @schema
81
+ input_schema: cleaned_schema(@schema)
76
82
  }
77
83
  end
78
84
 
@@ -86,22 +92,62 @@ module MCPClient
86
92
  }
87
93
  end
88
94
 
89
- # Check if the tool is marked as read-only
95
+ # Check if the tool is marked as read-only (legacy annotation field)
90
96
  # @return [Boolean] true if the tool is read-only
97
+ # @see #read_only_hint? for MCP 2025-11-25 annotation
91
98
  def read_only?
92
- @annotations && @annotations['readOnly'] == true
99
+ !!(@annotations && @annotations['readOnly'] == true)
93
100
  end
94
101
 
95
- # Check if the tool is marked as destructive
102
+ # Check if the tool is marked as destructive (legacy annotation field)
96
103
  # @return [Boolean] true if the tool is destructive
104
+ # @see #destructive_hint? for MCP 2025-11-25 annotation
97
105
  def destructive?
98
- @annotations && @annotations['destructive'] == true
106
+ !!(@annotations && @annotations['destructive'] == true)
99
107
  end
100
108
 
101
109
  # Check if the tool requires confirmation before execution
102
110
  # @return [Boolean] true if the tool requires confirmation
103
111
  def requires_confirmation?
104
- @annotations && @annotations['requiresConfirmation'] == true
112
+ !!(@annotations && @annotations['requiresConfirmation'] == true)
113
+ end
114
+
115
+ # Check the readOnlyHint annotation (MCP 2025-11-25)
116
+ # When true, the tool does not modify its environment.
117
+ # @return [Boolean] defaults to true when not specified
118
+ def read_only_hint?
119
+ return true unless @annotations
120
+
121
+ fetch_annotation_hint('readOnlyHint', :readOnlyHint, true)
122
+ end
123
+
124
+ # Check the destructiveHint annotation (MCP 2025-11-25)
125
+ # When true, the tool may perform destructive updates.
126
+ # Only meaningful when readOnlyHint is false.
127
+ # @return [Boolean] defaults to false when not specified
128
+ def destructive_hint?
129
+ return false unless @annotations
130
+
131
+ fetch_annotation_hint('destructiveHint', :destructiveHint, false)
132
+ end
133
+
134
+ # Check the idempotentHint annotation (MCP 2025-11-25)
135
+ # When true, calling the tool repeatedly with the same arguments has no additional effect.
136
+ # Only meaningful when readOnlyHint is false.
137
+ # @return [Boolean] defaults to false when not specified
138
+ def idempotent_hint?
139
+ return false unless @annotations
140
+
141
+ fetch_annotation_hint('idempotentHint', :idempotentHint, false)
142
+ end
143
+
144
+ # Check the openWorldHint annotation (MCP 2025-11-25)
145
+ # When true, the tool may interact with the "open world" (external entities).
146
+ # @return [Boolean] defaults to true when not specified
147
+ def open_world_hint?
148
+ return true unless @annotations
149
+
150
+ fetch_annotation_hint('openWorldHint', :openWorldHint, true)
105
151
  end
106
152
 
107
153
  # Check if the tool supports structured outputs (MCP 2025-06-18)
@@ -112,6 +158,24 @@ module MCPClient
112
158
 
113
159
  private
114
160
 
161
+ # Fetch a boolean annotation hint, checking both string and symbol keys.
162
+ # Uses Hash#key? to correctly handle false values.
163
+ # @param str_key [String] the string key to check
164
+ # @param sym_key [Symbol] the symbol key to check
165
+ # @param default [Boolean] the default value when the key is not present
166
+ # @return [Boolean] the annotation value, or the default
167
+ def fetch_annotation_hint(str_key, sym_key, default)
168
+ return default unless @annotations.is_a?(Hash)
169
+
170
+ if @annotations.key?(str_key)
171
+ @annotations[str_key]
172
+ elsif @annotations.key?(sym_key)
173
+ @annotations[sym_key]
174
+ else
175
+ default
176
+ end
177
+ end
178
+
115
179
  # Recursively remove "$schema" keys that are not accepted by Vertex AI
116
180
  # @param obj [Object] schema element (Hash/Array/other)
117
181
  # @return [Object] cleaned schema without "$schema" keys
@@ -2,8 +2,8 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.9.0'
5
+ VERSION = '1.0.0'
6
6
 
7
7
  # MCP protocol version (date-based) - unified across all transports
8
- PROTOCOL_VERSION = '2025-06-18'
8
+ PROTOCOL_VERSION = '2025-11-25'
9
9
  end
data/lib/mcp_client.rb CHANGED
@@ -7,6 +7,11 @@ 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/audio_content'
11
+ require_relative 'mcp_client/resource_link'
12
+ require_relative 'mcp_client/root'
13
+ require_relative 'mcp_client/elicitation_validator'
14
+ require_relative 'mcp_client/task'
10
15
  require_relative 'mcp_client/server_base'
11
16
  require_relative 'mcp_client/server_stdio'
12
17
  require_relative 'mcp_client/server_sse'
@@ -23,6 +28,312 @@ require_relative 'mcp_client/oauth_client'
23
28
  # Provides a standardized way for agents to communicate with external tools and services
24
29
  # through a protocol-based approach
25
30
  module MCPClient
31
+ # Simplified connection API - auto-detects transport and returns connected client
32
+ #
33
+ # @param target [String, Array<String>] URL(s) or command for connection
34
+ # - URLs ending in /sse -> SSE transport
35
+ # - URLs ending in /mcp -> Streamable HTTP transport
36
+ # - stdio://command or Array commands -> stdio transport
37
+ # - Commands starting with npx, node, python, ruby, etc. -> stdio transport
38
+ # - Other HTTP URLs -> Try Streamable HTTP, fallback to SSE, then HTTP
39
+ # Accepts keyword arguments for connection options:
40
+ # - headers [Hash] HTTP headers for remote transports
41
+ # - read_timeout [Integer] Request timeout in seconds (default: 30)
42
+ # - retries [Integer] Retry attempts
43
+ # - retry_backoff [Numeric] Backoff delay (default: 1)
44
+ # - name [String] Optional server name
45
+ # - logger [Logger] Optional logger
46
+ # - env [Hash] Environment variables for stdio
47
+ # - ping [Integer] Ping interval for SSE (default: 10)
48
+ # - endpoint [String] JSON-RPC endpoint path (default: '/rpc')
49
+ # - transport [Symbol] Force transport type (:stdio, :sse, :http, :streamable_http)
50
+ # - sampling_handler [Proc] Handler for sampling requests
51
+ # @yield [Faraday::Connection] Optional block for Faraday customization
52
+ # @return [MCPClient::Client] Connected client ready to use
53
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
54
+ # @raise [MCPClient::Errors::TransportDetectionError] if transport cannot be determined
55
+ #
56
+ # @example Connect to SSE server
57
+ # client = MCPClient.connect('http://localhost:8000/sse')
58
+ #
59
+ # @example Connect to Streamable HTTP server
60
+ # client = MCPClient.connect('http://localhost:8000/mcp')
61
+ #
62
+ # @example Connect with options
63
+ # client = MCPClient.connect('http://api.example.com/mcp',
64
+ # headers: { 'Authorization' => 'Bearer token' },
65
+ # read_timeout: 60
66
+ # )
67
+ #
68
+ # @example Connect to stdio server
69
+ # client = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem /home')
70
+ # # or with Array
71
+ # client = MCPClient.connect(['npx', '-y', '@modelcontextprotocol/server-filesystem', '/home'])
72
+ #
73
+ # @example Connect to multiple servers
74
+ # client = MCPClient.connect(['http://server1/mcp', 'http://server2/sse'])
75
+ #
76
+ # @example Force transport type
77
+ # client = MCPClient.connect('http://custom-server.com', transport: :streamable_http)
78
+ #
79
+ # @example With Faraday customization
80
+ # client = MCPClient.connect('https://internal.server.com/mcp') do |faraday|
81
+ # faraday.ssl.cert_store = custom_cert_store
82
+ # end
83
+ def self.connect(target, **, &)
84
+ # Handle array targets: either a single stdio command or multiple server URLs
85
+ if target.is_a?(Array)
86
+ # Check if it's a stdio command array (elements are command parts, not URLs)
87
+ if stdio_command_array?(target)
88
+ connect_single(target, **, &)
89
+ else
90
+ # It's an array of server URLs/commands
91
+ connect_multiple(target, **, &)
92
+ end
93
+ else
94
+ connect_single(target, **, &)
95
+ end
96
+ end
97
+
98
+ class << self
99
+ private
100
+
101
+ # Connect to a single server
102
+ def connect_single(target, **options, &)
103
+ transport = options[:transport]&.to_sym || detect_transport(target)
104
+
105
+ case transport
106
+ when :stdio
107
+ connect_stdio(target, **options)
108
+ when :sse
109
+ connect_sse(target, **options)
110
+ when :http
111
+ connect_http(target, **options, &)
112
+ when :streamable_http
113
+ connect_streamable_http(target, **options, &)
114
+ when :auto
115
+ connect_with_fallback(target, **options, &)
116
+ else
117
+ raise Errors::TransportDetectionError, "Unknown transport: #{transport}"
118
+ end
119
+ end
120
+
121
+ # Connect to multiple servers
122
+ def connect_multiple(targets, **options, &faraday_config)
123
+ configs = targets.map.with_index do |t, idx|
124
+ server_name = options[:name] ? "#{options[:name]}_#{idx}" : "server_#{idx}"
125
+ build_config_for_target(t, **options.merge(name: server_name), &faraday_config)
126
+ end
127
+
128
+ client = Client.new(
129
+ mcp_server_configs: configs,
130
+ logger: options[:logger],
131
+ sampling_handler: options[:sampling_handler]
132
+ )
133
+
134
+ # Connect all servers
135
+ client.servers.each(&:connect)
136
+ client
137
+ end
138
+
139
+ # Connect via stdio transport
140
+ def connect_stdio(target, **options)
141
+ command = parse_stdio_command(target)
142
+ config = stdio_config(command: command, **extract_stdio_options(options))
143
+ create_and_connect_client(config, options)
144
+ end
145
+
146
+ # Connect via SSE transport
147
+ def connect_sse(url, **options)
148
+ config = sse_config(base_url: url.to_s, **extract_sse_options(options))
149
+ create_and_connect_client(config, options)
150
+ end
151
+
152
+ # Connect via HTTP transport
153
+ def connect_http(url, **options, &)
154
+ config = http_config(base_url: url.to_s, **extract_http_options(options), &)
155
+ create_and_connect_client(config, options)
156
+ end
157
+
158
+ # Connect via Streamable HTTP transport
159
+ def connect_streamable_http(url, **options, &)
160
+ config = streamable_http_config(base_url: url.to_s, **extract_http_options(options), &)
161
+ create_and_connect_client(config, options)
162
+ end
163
+
164
+ # Create client and connect to server
165
+ def create_and_connect_client(config, options)
166
+ client = Client.new(
167
+ mcp_server_configs: [config],
168
+ logger: options[:logger],
169
+ sampling_handler: options[:sampling_handler]
170
+ )
171
+ client.servers.first.connect
172
+ client
173
+ end
174
+
175
+ # Try transports in order until one succeeds
176
+ def connect_with_fallback(url, **options, &)
177
+ require 'logger'
178
+ logger = options[:logger] || Logger.new($stderr, level: Logger::WARN)
179
+ errors = []
180
+
181
+ # Try Streamable HTTP first (most modern)
182
+ begin
183
+ logger.debug("MCPClient.connect: Attempting Streamable HTTP connection to #{url}")
184
+ return connect_streamable_http(url, **options, &)
185
+ rescue Errors::ConnectionError, Errors::TransportError => e
186
+ errors << "Streamable HTTP: #{e.message}"
187
+ logger.debug("MCPClient.connect: Streamable HTTP failed: #{e.message}")
188
+ end
189
+
190
+ # Try SSE second
191
+ begin
192
+ logger.debug("MCPClient.connect: Attempting SSE connection to #{url}")
193
+ return connect_sse(url, **options)
194
+ rescue Errors::ConnectionError, Errors::TransportError => e
195
+ errors << "SSE: #{e.message}"
196
+ logger.debug("MCPClient.connect: SSE failed: #{e.message}")
197
+ end
198
+
199
+ # Try plain HTTP last
200
+ begin
201
+ logger.debug("MCPClient.connect: Attempting HTTP connection to #{url}")
202
+ return connect_http(url, **options, &)
203
+ rescue Errors::ConnectionError, Errors::TransportError => e
204
+ errors << "HTTP: #{e.message}"
205
+ logger.debug("MCPClient.connect: HTTP failed: #{e.message}")
206
+ end
207
+
208
+ raise Errors::ConnectionError,
209
+ "Failed to connect to #{url}. Tried all transports:\n #{errors.join("\n ")}"
210
+ end
211
+
212
+ # Detect transport type from target
213
+ def detect_transport(target)
214
+ return :stdio if target.is_a?(Array) && stdio_command_array?(target)
215
+ return :stdio if stdio_target?(target)
216
+
217
+ uri = begin
218
+ URI.parse(target.to_s)
219
+ rescue URI::InvalidURIError
220
+ raise Errors::TransportDetectionError, "Invalid URL: #{target}"
221
+ end
222
+
223
+ unless http_url?(uri)
224
+ raise Errors::TransportDetectionError,
225
+ "Cannot detect transport for non-HTTP URL: #{target}. " \
226
+ 'Use transport: option to specify explicitly.'
227
+ end
228
+
229
+ path = uri.path.to_s.downcase
230
+ return :sse if path.end_with?('/sse')
231
+ return :streamable_http if path.end_with?('/mcp')
232
+
233
+ # Ambiguous HTTP URL - use fallback strategy
234
+ :auto
235
+ end
236
+
237
+ # Check if target is a stdio command (string form)
238
+ def stdio_target?(target)
239
+ return false if target.is_a?(Array) # Arrays handled separately by stdio_command_array?
240
+
241
+ target_str = target.to_s
242
+ return true if target_str.start_with?('stdio://')
243
+ return true if target_str.match?(/^(npx|node|python|python3|ruby|php|java|cargo|go run)\b/)
244
+
245
+ false
246
+ end
247
+
248
+ # Check if an array represents a single stdio command (vs multiple server URLs)
249
+ # A stdio command array has elements that are command parts, not URLs
250
+ def stdio_command_array?(arr)
251
+ return false unless arr.is_a?(Array) && arr.any?
252
+
253
+ first = arr.first.to_s
254
+ # If the first element looks like a URL, it's not a stdio command array
255
+ return false if first.match?(%r{^https?://})
256
+ return false if first.start_with?('stdio://')
257
+
258
+ # If the first element is a known command executable, it's a stdio command array
259
+ return true if first.match?(/^(npx|node|python|python3|ruby|php|java|cargo|go)$/)
260
+
261
+ # If none of the elements look like URLs, assume it's a command array
262
+ arr.none? { |el| el.to_s.match?(%r{^https?://}) }
263
+ end
264
+
265
+ # Check if URI is HTTP/HTTPS
266
+ def http_url?(uri)
267
+ %w[http https].include?(uri.scheme&.downcase)
268
+ end
269
+
270
+ # Parse stdio command from various formats
271
+ def parse_stdio_command(target)
272
+ return target if target.is_a?(Array)
273
+
274
+ target_str = target.to_s
275
+ if target_str.start_with?('stdio://')
276
+ target_str.sub('stdio://', '')
277
+ else
278
+ target_str
279
+ end
280
+ end
281
+
282
+ # Extract common options shared by all transports
283
+ def extract_common_options(options)
284
+ {
285
+ name: options[:name],
286
+ logger: options[:logger],
287
+ read_timeout: options[:read_timeout],
288
+ retries: options[:retries],
289
+ retry_backoff: options[:retry_backoff]
290
+ }.compact
291
+ end
292
+
293
+ # Extract HTTP transport specific options
294
+ def extract_http_options(options)
295
+ extract_common_options(options).merge({
296
+ headers: options[:headers] || {},
297
+ endpoint: options[:endpoint]
298
+ }.compact)
299
+ end
300
+
301
+ # Extract SSE transport specific options
302
+ def extract_sse_options(options)
303
+ extract_common_options(options).merge({
304
+ headers: options[:headers] || {},
305
+ ping: options[:ping]
306
+ }.compact)
307
+ end
308
+
309
+ # Extract stdio transport specific options
310
+ def extract_stdio_options(options)
311
+ extract_common_options(options).merge({
312
+ env: options[:env] || {}
313
+ }.compact)
314
+ end
315
+
316
+ # Build config hash for a target
317
+ def build_config_for_target(target, **options, &)
318
+ transport = options[:transport]&.to_sym || detect_transport(target)
319
+
320
+ case transport
321
+ when :stdio
322
+ command = parse_stdio_command(target)
323
+ stdio_config(command: command, **extract_stdio_options(options))
324
+ when :sse
325
+ sse_config(base_url: target.to_s, **extract_sse_options(options))
326
+ when :http
327
+ http_config(base_url: target.to_s, **extract_http_options(options), &)
328
+ when :streamable_http, :auto
329
+ # For multi-server, default to streamable_http without fallback
330
+ streamable_http_config(base_url: target.to_s, **extract_http_options(options), &)
331
+ else
332
+ raise Errors::TransportDetectionError, "Unknown transport: #{transport}"
333
+ end
334
+ end
335
+ end
336
+
26
337
  # Create a new MCPClient client
27
338
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
28
339
  # @param server_definition_file [String, nil] optional path to a JSON file defining server configurations
@@ -112,9 +423,11 @@ module MCPClient
112
423
  # @param retry_backoff [Integer] backoff delay in seconds (default: 1)
113
424
  # @param name [String, nil] optional name for this server
114
425
  # @param logger [Logger, nil] optional logger for server operations
426
+ # @yieldparam faraday [Faraday::Connection] the configured connection instance for additional customization
427
+ # (e.g., SSL settings, custom middleware). The block is called after default configuration is applied.
115
428
  # @return [Hash] server configuration
116
429
  def self.http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3, retry_backoff: 1,
117
- name: nil, logger: nil)
430
+ name: nil, logger: nil, &faraday_config)
118
431
  {
119
432
  type: 'http',
120
433
  base_url: base_url,
@@ -124,7 +437,8 @@ module MCPClient
124
437
  retries: retries,
125
438
  retry_backoff: retry_backoff,
126
439
  name: name,
127
- logger: logger
440
+ logger: logger,
441
+ faraday_config: faraday_config
128
442
  }
129
443
  end
130
444
 
@@ -138,9 +452,11 @@ module MCPClient
138
452
  # @param retry_backoff [Integer] Backoff delay in seconds (default: 1)
139
453
  # @param name [String, nil] Optional name for this server
140
454
  # @param logger [Logger, nil] Optional logger for server operations
455
+ # @yieldparam faraday [Faraday::Connection] the configured connection instance for additional customization
456
+ # (e.g., SSL settings, custom middleware). The block is called after default configuration is applied.
141
457
  # @return [Hash] server configuration
142
458
  def self.streamable_http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3,
143
- retry_backoff: 1, name: nil, logger: nil)
459
+ retry_backoff: 1, name: nil, logger: nil, &faraday_config)
144
460
  {
145
461
  type: 'streamable_http',
146
462
  base_url: base_url,
@@ -150,7 +466,31 @@ module MCPClient
150
466
  retries: retries,
151
467
  retry_backoff: retry_backoff,
152
468
  name: name,
153
- logger: logger
469
+ logger: logger,
470
+ faraday_config: faraday_config
154
471
  }
155
472
  end
473
+
474
+ # Parse a single content item from a tool result into a typed object
475
+ # Recognizes 'resource_link' type and returns an MCPClient::ResourceLink.
476
+ # Unrecognized types are returned as-is (the original Hash).
477
+ # @param item [Hash] a content item with a 'type' field
478
+ # @return [MCPClient::ResourceLink, Hash] typed object or raw hash
479
+ def self.parse_content_item(item)
480
+ case item['type']
481
+ when 'resource_link'
482
+ ResourceLink.from_json(item)
483
+ else
484
+ item
485
+ end
486
+ end
487
+
488
+ # Parse the content array from a tool result into typed objects
489
+ # Each item with type 'resource_link' is converted to an MCPClient::ResourceLink.
490
+ # Other items are returned as-is.
491
+ # @param content [Array<Hash>] content array from a tool result
492
+ # @return [Array<MCPClient::ResourceLink, Hash>] array of typed objects or raw hashes
493
+ def self.parse_tool_content(content)
494
+ Array(content).map { |item| parse_content_item(item) }
495
+ end
156
496
  end