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.
- checksums.yaml +4 -4
- data/README.md +228 -1227
- data/lib/mcp_client/audio_content.rb +46 -0
- data/lib/mcp_client/client.rb +432 -37
- data/lib/mcp_client/elicitation_validator.rb +262 -0
- data/lib/mcp_client/errors.rb +9 -0
- data/lib/mcp_client/http_transport_base.rb +9 -1
- data/lib/mcp_client/json_rpc_common.rb +7 -3
- data/lib/mcp_client/resource.rb +8 -0
- data/lib/mcp_client/resource_link.rb +63 -0
- data/lib/mcp_client/root.rb +63 -0
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +46 -6
- data/lib/mcp_client/server_sse.rb +133 -5
- data/lib/mcp_client/server_stdio.rb +143 -0
- data/lib/mcp_client/server_streamable_http.rb +148 -5
- data/lib/mcp_client/task.rb +127 -0
- data/lib/mcp_client/tool.rb +73 -9
- data/lib/mcp_client/version.rb +2 -2
- data/lib/mcp_client.rb +344 -4
- metadata +9 -4
|
@@ -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
|
data/lib/mcp_client/tool.rb
CHANGED
|
@@ -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
|
data/lib/mcp_client/version.rb
CHANGED
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
|