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,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Validates elicitation schemas and content per MCP 2025-11-25 spec.
|
|
5
|
+
# Schemas are restricted to flat objects with primitive property types:
|
|
6
|
+
# string (with optional enum, pattern, format, minLength, maxLength)
|
|
7
|
+
# number / integer (with optional minimum, maximum)
|
|
8
|
+
# boolean
|
|
9
|
+
# array (multi-select enum only, with items containing enum or anyOf)
|
|
10
|
+
module ElicitationValidator
|
|
11
|
+
# Allowed primitive types for schema properties
|
|
12
|
+
PRIMITIVE_TYPES = %w[string number integer boolean].freeze
|
|
13
|
+
|
|
14
|
+
# Allowed string formats per MCP spec
|
|
15
|
+
STRING_FORMATS = %w[email uri date date-time].freeze
|
|
16
|
+
|
|
17
|
+
# Validate that a requestedSchema conforms to MCP elicitation constraints.
|
|
18
|
+
# Returns an array of error messages (empty if valid).
|
|
19
|
+
# @param schema [Hash] the requestedSchema
|
|
20
|
+
# @return [Array<String>] validation errors
|
|
21
|
+
def self.validate_schema(schema)
|
|
22
|
+
errors = []
|
|
23
|
+
return errors unless schema.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
unless schema['type'] == 'object'
|
|
26
|
+
errors << "Schema type must be 'object', got '#{schema['type']}'"
|
|
27
|
+
return errors
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
properties = schema['properties']
|
|
31
|
+
return errors unless properties.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
properties.each do |name, prop|
|
|
34
|
+
errors.concat(validate_property(name, prop))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate a single property definition.
|
|
41
|
+
# @param name [String] property name
|
|
42
|
+
# @param prop [Hash] property schema
|
|
43
|
+
# @return [Array<String>] validation errors
|
|
44
|
+
def self.validate_property(name, prop)
|
|
45
|
+
errors = []
|
|
46
|
+
return errors unless prop.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
type = prop['type']
|
|
49
|
+
|
|
50
|
+
if type == 'array'
|
|
51
|
+
errors.concat(validate_array_property(name, prop))
|
|
52
|
+
elsif PRIMITIVE_TYPES.include?(type)
|
|
53
|
+
errors.concat(validate_primitive_property(name, prop))
|
|
54
|
+
else
|
|
55
|
+
errors << "Property '#{name}' has unsupported type '#{type}'"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
errors
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validate a primitive property (string, number, integer, boolean).
|
|
62
|
+
# @param name [String] property name
|
|
63
|
+
# @param prop [Hash] property schema
|
|
64
|
+
# @return [Array<String>] validation errors
|
|
65
|
+
def self.validate_primitive_property(name, prop)
|
|
66
|
+
errors = []
|
|
67
|
+
type = prop['type']
|
|
68
|
+
|
|
69
|
+
case type
|
|
70
|
+
when 'string'
|
|
71
|
+
if prop['format'] && !STRING_FORMATS.include?(prop['format'])
|
|
72
|
+
errors << "Property '#{name}' has unsupported format '#{prop['format']}'"
|
|
73
|
+
end
|
|
74
|
+
errors << "Property '#{name}' enum must be an array" if prop['enum'] && !prop['enum'].is_a?(Array)
|
|
75
|
+
when 'number', 'integer'
|
|
76
|
+
if prop.key?('minimum') && !prop['minimum'].is_a?(Numeric)
|
|
77
|
+
errors << "Property '#{name}' minimum must be numeric"
|
|
78
|
+
end
|
|
79
|
+
if prop.key?('maximum') && !prop['maximum'].is_a?(Numeric)
|
|
80
|
+
errors << "Property '#{name}' maximum must be numeric"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
errors
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Validate an array property (multi-select enum only).
|
|
88
|
+
# @param name [String] property name
|
|
89
|
+
# @param prop [Hash] property schema
|
|
90
|
+
# @return [Array<String>] validation errors
|
|
91
|
+
def self.validate_array_property(name, prop)
|
|
92
|
+
errors = []
|
|
93
|
+
items = prop['items']
|
|
94
|
+
|
|
95
|
+
unless items.is_a?(Hash)
|
|
96
|
+
errors << "Property '#{name}' array type requires 'items' definition"
|
|
97
|
+
return errors
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
has_enum = items['enum'].is_a?(Array)
|
|
101
|
+
has_any_of = items['anyOf'].is_a?(Array)
|
|
102
|
+
|
|
103
|
+
errors << "Property '#{name}' array items must have 'enum' or 'anyOf'" unless has_enum || has_any_of
|
|
104
|
+
|
|
105
|
+
errors
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Validate content against a requestedSchema.
|
|
109
|
+
# Returns an array of error messages (empty if valid).
|
|
110
|
+
# @param content [Hash] the response content
|
|
111
|
+
# @param schema [Hash] the requestedSchema
|
|
112
|
+
# @return [Array<String>] validation errors
|
|
113
|
+
def self.validate_content(content, schema)
|
|
114
|
+
errors = []
|
|
115
|
+
return errors unless content.is_a?(Hash) && schema.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
properties = schema['properties'] || {}
|
|
118
|
+
required = Array(schema['required'])
|
|
119
|
+
|
|
120
|
+
# Check required fields
|
|
121
|
+
required.each do |field|
|
|
122
|
+
field_s = field.to_s
|
|
123
|
+
errors << "Missing required field '#{field_s}'" unless content.key?(field_s) || content.key?(field_s.to_sym)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Validate each provided field
|
|
127
|
+
content.each do |field, value|
|
|
128
|
+
prop = properties[field.to_s]
|
|
129
|
+
next unless prop.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
errors.concat(validate_value(field.to_s, value, prop))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
errors
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate a single value against its property schema.
|
|
138
|
+
# @param field [String] field name
|
|
139
|
+
# @param value [Object] the value to validate
|
|
140
|
+
# @param prop [Hash] property schema
|
|
141
|
+
# @return [Array<String>] validation errors
|
|
142
|
+
def self.validate_value(field, value, prop)
|
|
143
|
+
errors = []
|
|
144
|
+
type = prop['type']
|
|
145
|
+
|
|
146
|
+
case type
|
|
147
|
+
when 'string'
|
|
148
|
+
errors.concat(validate_string_value(field, value, prop))
|
|
149
|
+
when 'number', 'integer'
|
|
150
|
+
errors.concat(validate_number_value(field, value, prop))
|
|
151
|
+
when 'boolean'
|
|
152
|
+
errors << "Field '#{field}' must be a boolean" unless [true, false].include?(value)
|
|
153
|
+
when 'array'
|
|
154
|
+
errors.concat(validate_array_value(field, value, prop))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
errors
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Validate a string value against its property schema.
|
|
161
|
+
# @param field [String] field name
|
|
162
|
+
# @param value [Object] the value
|
|
163
|
+
# @param prop [Hash] property schema
|
|
164
|
+
# @return [Array<String>] validation errors
|
|
165
|
+
def self.validate_string_value(field, value, prop)
|
|
166
|
+
errors = []
|
|
167
|
+
|
|
168
|
+
unless value.is_a?(String)
|
|
169
|
+
errors << "Field '#{field}' must be a string"
|
|
170
|
+
return errors
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if prop['enum'].is_a?(Array) && !prop['enum'].include?(value)
|
|
174
|
+
errors << "Field '#{field}' must be one of: #{prop['enum'].join(', ')}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if prop['oneOf'].is_a?(Array)
|
|
178
|
+
allowed = prop['oneOf'].map { |o| o['const'] }
|
|
179
|
+
errors << "Field '#{field}' must be one of: #{allowed.join(', ')}" unless allowed.include?(value)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if prop['pattern']
|
|
183
|
+
begin
|
|
184
|
+
unless value.match?(Regexp.new(prop['pattern']))
|
|
185
|
+
errors << "Field '#{field}' must match pattern '#{prop['pattern']}'"
|
|
186
|
+
end
|
|
187
|
+
rescue RegexpError
|
|
188
|
+
# Skip pattern validation if the pattern is invalid
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if prop['minLength'] && value.length < prop['minLength']
|
|
193
|
+
errors << "Field '#{field}' must be at least #{prop['minLength']} characters"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if prop['maxLength'] && value.length > prop['maxLength']
|
|
197
|
+
errors << "Field '#{field}' must be at most #{prop['maxLength']} characters"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
errors
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Validate a number value against its property schema.
|
|
204
|
+
# @param field [String] field name
|
|
205
|
+
# @param value [Object] the value
|
|
206
|
+
# @param prop [Hash] property schema
|
|
207
|
+
# @return [Array<String>] validation errors
|
|
208
|
+
def self.validate_number_value(field, value, prop)
|
|
209
|
+
errors = []
|
|
210
|
+
|
|
211
|
+
unless value.is_a?(Numeric)
|
|
212
|
+
errors << "Field '#{field}' must be a number"
|
|
213
|
+
return errors
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
errors << "Field '#{field}' must be an integer" if prop['type'] == 'integer' && !value.is_a?(Integer)
|
|
217
|
+
|
|
218
|
+
errors << "Field '#{field}' must be >= #{prop['minimum']}" if prop['minimum'] && value < prop['minimum']
|
|
219
|
+
|
|
220
|
+
errors << "Field '#{field}' must be <= #{prop['maximum']}" if prop['maximum'] && value > prop['maximum']
|
|
221
|
+
|
|
222
|
+
errors
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Validate an array value against its property schema (multi-select enum).
|
|
226
|
+
# @param field [String] field name
|
|
227
|
+
# @param value [Object] the value
|
|
228
|
+
# @param prop [Hash] property schema
|
|
229
|
+
# @return [Array<String>] validation errors
|
|
230
|
+
def self.validate_array_value(field, value, prop)
|
|
231
|
+
errors = []
|
|
232
|
+
|
|
233
|
+
unless value.is_a?(Array)
|
|
234
|
+
errors << "Field '#{field}' must be an array"
|
|
235
|
+
return errors
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
items = prop['items'] || {}
|
|
239
|
+
allowed = if items['enum'].is_a?(Array)
|
|
240
|
+
items['enum']
|
|
241
|
+
elsif items['anyOf'].is_a?(Array)
|
|
242
|
+
items['anyOf'].map { |o| o['const'] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if allowed
|
|
246
|
+
value.each do |v|
|
|
247
|
+
errors << "Field '#{field}' contains invalid value '#{v}'" unless allowed.include?(v)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if prop['minItems'] && value.length < prop['minItems']
|
|
252
|
+
errors << "Field '#{field}' must have at least #{prop['minItems']} items"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if prop['maxItems'] && value.length > prop['maxItems']
|
|
256
|
+
errors << "Field '#{field}' must have at most #{prop['maxItems']} items"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
errors
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -47,5 +47,14 @@ module MCPClient
|
|
|
47
47
|
|
|
48
48
|
# Raised when multiple resources with the same URI exist across different servers
|
|
49
49
|
class AmbiguousResourceURI < MCPError; end
|
|
50
|
+
|
|
51
|
+
# Raised when transport type cannot be determined from target URL/command
|
|
52
|
+
class TransportDetectionError < MCPError; end
|
|
53
|
+
|
|
54
|
+
# Raised when a task is not found
|
|
55
|
+
class TaskNotFound < MCPError; end
|
|
56
|
+
|
|
57
|
+
# Raised when there's an error creating or managing a task
|
|
58
|
+
class TaskError < MCPError; end
|
|
50
59
|
end
|
|
51
60
|
end
|
|
@@ -58,6 +58,7 @@ module MCPClient
|
|
|
58
58
|
# Apply base headers but prioritize session termination headers
|
|
59
59
|
@headers.each { |k, v| req.headers[k] = v }
|
|
60
60
|
req.headers['Mcp-Session-Id'] = @session_id
|
|
61
|
+
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
if response.success?
|
|
@@ -138,6 +139,7 @@ module MCPClient
|
|
|
138
139
|
|
|
139
140
|
@server_info = result['serverInfo']
|
|
140
141
|
@capabilities = result['capabilities']
|
|
142
|
+
@protocol_version = result['protocolVersion']
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
# Send a JSON-RPC request to the server and wait for result
|
|
@@ -256,14 +258,20 @@ module MCPClient
|
|
|
256
258
|
end
|
|
257
259
|
|
|
258
260
|
# Create a Faraday connection for HTTP requests
|
|
261
|
+
# Applies default configuration first, then allows user customization via @faraday_config block
|
|
259
262
|
# @return [Faraday::Connection] the configured connection
|
|
260
263
|
def create_http_connection
|
|
261
|
-
Faraday.new(url: @base_url) do |f|
|
|
264
|
+
conn = Faraday.new(url: @base_url) do |f|
|
|
262
265
|
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
|
263
266
|
f.options.open_timeout = @read_timeout
|
|
264
267
|
f.options.timeout = @read_timeout
|
|
265
268
|
f.adapter Faraday.default_adapter
|
|
266
269
|
end
|
|
270
|
+
|
|
271
|
+
# Apply user's Faraday customizations after defaults
|
|
272
|
+
@faraday_config&.call(conn)
|
|
273
|
+
|
|
274
|
+
conn
|
|
267
275
|
end
|
|
268
276
|
|
|
269
277
|
# Log HTTP response (to be overridden by specific transports)
|
|
@@ -62,11 +62,15 @@ module MCPClient
|
|
|
62
62
|
# Generate initialization parameters for MCP protocol
|
|
63
63
|
# @return [Hash] the initialization parameters
|
|
64
64
|
def initialization_params
|
|
65
|
+
capabilities = {
|
|
66
|
+
'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
|
|
67
|
+
'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
|
|
68
|
+
'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
{
|
|
66
72
|
'protocolVersion' => MCPClient::PROTOCOL_VERSION,
|
|
67
|
-
'capabilities' =>
|
|
68
|
-
'elicitation' => {} # MCP 2025-06-18: Support for server-initiated user interactions
|
|
69
|
-
},
|
|
73
|
+
'capabilities' => capabilities,
|
|
70
74
|
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
|
71
75
|
}
|
|
72
76
|
end
|
data/lib/mcp_client/resource.rb
CHANGED
|
@@ -41,6 +41,14 @@ module MCPClient
|
|
|
41
41
|
@server = server
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Return the lastModified annotation value (ISO 8601 timestamp string)
|
|
45
|
+
# @return [String, nil] the lastModified timestamp, or nil if not set
|
|
46
|
+
def last_modified
|
|
47
|
+
return nil unless @annotations.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
@annotations['lastModified'] || @annotations[:lastModified]
|
|
50
|
+
end
|
|
51
|
+
|
|
44
52
|
# Create a Resource instance from JSON data
|
|
45
53
|
# @param data [Hash] JSON data from MCP server
|
|
46
54
|
# @param server [MCPClient::ServerBase, nil] the server this resource belongs to
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Representation of an MCP resource link in tool result content
|
|
5
|
+
# A resource link references a server resource that can be read separately.
|
|
6
|
+
# Used in tool results to point clients to available resources (MCP 2025-11-25).
|
|
7
|
+
class ResourceLink
|
|
8
|
+
# @!attribute [r] uri
|
|
9
|
+
# @return [String] URI of the linked resource
|
|
10
|
+
# @!attribute [r] name
|
|
11
|
+
# @return [String] the name of the linked resource
|
|
12
|
+
# @!attribute [r] description
|
|
13
|
+
# @return [String, nil] optional human-readable description
|
|
14
|
+
# @!attribute [r] mime_type
|
|
15
|
+
# @return [String, nil] optional MIME type of the resource
|
|
16
|
+
# @!attribute [r] annotations
|
|
17
|
+
# @return [Hash, nil] optional annotations that provide hints to clients
|
|
18
|
+
# @!attribute [r] title
|
|
19
|
+
# @return [String, nil] optional display title for the resource
|
|
20
|
+
# @!attribute [r] size
|
|
21
|
+
# @return [Integer, nil] optional size of the resource in bytes
|
|
22
|
+
attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size
|
|
23
|
+
|
|
24
|
+
# Initialize a resource link
|
|
25
|
+
# @param uri [String] URI of the linked resource
|
|
26
|
+
# @param name [String] the name of the linked resource
|
|
27
|
+
# @param description [String, nil] optional human-readable description
|
|
28
|
+
# @param mime_type [String, nil] optional MIME type of the resource
|
|
29
|
+
# @param annotations [Hash, nil] optional annotations that provide hints to clients
|
|
30
|
+
# @param title [String, nil] optional display title for the resource
|
|
31
|
+
# @param size [Integer, nil] optional size of the resource in bytes
|
|
32
|
+
def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil)
|
|
33
|
+
@uri = uri
|
|
34
|
+
@name = name
|
|
35
|
+
@description = description
|
|
36
|
+
@mime_type = mime_type
|
|
37
|
+
@annotations = annotations
|
|
38
|
+
@title = title
|
|
39
|
+
@size = size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create a ResourceLink instance from JSON data
|
|
43
|
+
# @param data [Hash] JSON data from MCP server (content item with type 'resource_link')
|
|
44
|
+
# @return [MCPClient::ResourceLink] resource link instance
|
|
45
|
+
def self.from_json(data)
|
|
46
|
+
new(
|
|
47
|
+
uri: data['uri'],
|
|
48
|
+
name: data['name'],
|
|
49
|
+
description: data['description'],
|
|
50
|
+
mime_type: data['mimeType'],
|
|
51
|
+
annotations: data['annotations'],
|
|
52
|
+
title: data['title'],
|
|
53
|
+
size: data['size']
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The content type identifier for this content type
|
|
58
|
+
# @return [String] 'resource_link'
|
|
59
|
+
def type
|
|
60
|
+
'resource_link'
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Represents an MCP Root - a URI that defines a boundary where servers can operate
|
|
5
|
+
# Roots are declared by clients to inform servers about relevant resources and their locations
|
|
6
|
+
class Root
|
|
7
|
+
attr_reader :uri, :name
|
|
8
|
+
|
|
9
|
+
# Create a new Root
|
|
10
|
+
# @param uri [String] The URI for the root (typically file:// URI)
|
|
11
|
+
# @param name [String, nil] Optional human-readable name for display purposes
|
|
12
|
+
def initialize(uri:, name: nil)
|
|
13
|
+
@uri = uri
|
|
14
|
+
@name = name
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Create a Root from a JSON hash
|
|
18
|
+
# @param json [Hash] The JSON hash with 'uri' and optional 'name' keys
|
|
19
|
+
# @return [Root]
|
|
20
|
+
def self.from_json(json)
|
|
21
|
+
new(
|
|
22
|
+
uri: json['uri'] || json[:uri],
|
|
23
|
+
name: json['name'] || json[:name]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Convert to JSON-serializable hash
|
|
28
|
+
# @return [Hash]
|
|
29
|
+
def to_h
|
|
30
|
+
result = { 'uri' => @uri }
|
|
31
|
+
result['name'] = @name if @name
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convert to JSON string
|
|
36
|
+
# @return [String]
|
|
37
|
+
def to_json(*)
|
|
38
|
+
to_h.to_json(*)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check equality
|
|
42
|
+
def ==(other)
|
|
43
|
+
return false unless other.is_a?(Root)
|
|
44
|
+
|
|
45
|
+
uri == other.uri && name == other.name
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
alias eql? ==
|
|
49
|
+
|
|
50
|
+
def hash
|
|
51
|
+
[uri, name].hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# String representation
|
|
55
|
+
def to_s
|
|
56
|
+
name ? "#{name} (#{uri})" : uri
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inspect
|
|
60
|
+
"#<MCPClient::Root uri=#{uri.inspect} name=#{name.inspect}>"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -77,7 +77,8 @@ module MCPClient
|
|
|
77
77
|
retry_backoff: config[:retry_backoff] || 1,
|
|
78
78
|
name: config[:name],
|
|
79
79
|
logger: logger,
|
|
80
|
-
oauth_provider: config[:oauth_provider]
|
|
80
|
+
oauth_provider: config[:oauth_provider],
|
|
81
|
+
faraday_config: config[:faraday_config]
|
|
81
82
|
)
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -97,7 +98,8 @@ module MCPClient
|
|
|
97
98
|
retry_backoff: config[:retry_backoff] || 1,
|
|
98
99
|
name: config[:name],
|
|
99
100
|
logger: logger,
|
|
100
|
-
oauth_provider: config[:oauth_provider]
|
|
101
|
+
oauth_provider: config[:oauth_provider],
|
|
102
|
+
faraday_config: config[:faraday_config]
|
|
101
103
|
)
|
|
102
104
|
end
|
|
103
105
|
|
|
@@ -55,6 +55,7 @@ module MCPClient
|
|
|
55
55
|
# @option options [String, nil] :name Optional name for this server
|
|
56
56
|
# @option options [Logger, nil] :logger Optional logger
|
|
57
57
|
# @option options [MCPClient::Auth::OAuthProvider, nil] :oauth_provider Optional OAuth provider
|
|
58
|
+
# @option options [Proc, nil] :faraday_config Optional block to customize the Faraday connection
|
|
58
59
|
def initialize(base_url:, **options)
|
|
59
60
|
opts = default_options.merge(options)
|
|
60
61
|
super(name: opts[:name])
|
|
@@ -99,6 +100,7 @@ module MCPClient
|
|
|
99
100
|
})
|
|
100
101
|
|
|
101
102
|
@read_timeout = opts[:read_timeout]
|
|
103
|
+
@faraday_config = opts[:faraday_config]
|
|
102
104
|
@tools = nil
|
|
103
105
|
@tools_data = nil
|
|
104
106
|
@request_id = 0
|
|
@@ -193,15 +195,22 @@ module MCPClient
|
|
|
193
195
|
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
|
194
196
|
end
|
|
195
197
|
|
|
196
|
-
# Override apply_request_headers to add session
|
|
198
|
+
# Override apply_request_headers to add session and protocol version headers
|
|
197
199
|
def apply_request_headers(req, request)
|
|
198
200
|
super
|
|
199
201
|
|
|
200
|
-
# Add session
|
|
201
|
-
return unless
|
|
202
|
+
# Add session and protocol version headers for non-initialize requests
|
|
203
|
+
return unless request['method'] != 'initialize'
|
|
202
204
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
if @session_id
|
|
206
|
+
req.headers['Mcp-Session-Id'] = @session_id
|
|
207
|
+
@logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
return unless @protocol_version
|
|
211
|
+
|
|
212
|
+
req.headers['Mcp-Protocol-Version'] = @protocol_version
|
|
213
|
+
@logger.debug("Adding protocol version header: Mcp-Protocol-Version: #{@protocol_version}")
|
|
205
214
|
end
|
|
206
215
|
|
|
207
216
|
# Override handle_successful_response to capture session ID
|
|
@@ -320,6 +329,36 @@ module MCPClient
|
|
|
320
329
|
raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
|
|
321
330
|
end
|
|
322
331
|
|
|
332
|
+
# Request completion suggestions from the server (MCP 2025-06-18)
|
|
333
|
+
# @param ref [Hash] reference to complete (prompt or resource)
|
|
334
|
+
# @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
|
|
335
|
+
# @param context [Hash, nil] optional context for the completion (MCP 2025-11-25)
|
|
336
|
+
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
337
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
338
|
+
def complete(ref:, argument:, context: nil)
|
|
339
|
+
params = { ref: ref, argument: argument }
|
|
340
|
+
params[:context] = context if context
|
|
341
|
+
result = rpc_request('completion/complete', params)
|
|
342
|
+
result['completion'] || { 'values' => [] }
|
|
343
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
344
|
+
raise
|
|
345
|
+
rescue StandardError => e
|
|
346
|
+
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Set the logging level on the server (MCP 2025-06-18)
|
|
350
|
+
# @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
|
|
351
|
+
# 'critical', 'alert', 'emergency')
|
|
352
|
+
# @return [Hash] empty result on success
|
|
353
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
354
|
+
def log_level=(level)
|
|
355
|
+
rpc_request('logging/setLevel', { level: level })
|
|
356
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
357
|
+
raise
|
|
358
|
+
rescue StandardError => e
|
|
359
|
+
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
360
|
+
end
|
|
361
|
+
|
|
323
362
|
# List all resource templates available from the MCP server
|
|
324
363
|
# @param cursor [String, nil] optional cursor for pagination
|
|
325
364
|
# @return [Hash] result containing resourceTemplates array and optional nextCursor
|
|
@@ -420,7 +459,8 @@ module MCPClient
|
|
|
420
459
|
retry_backoff: 1,
|
|
421
460
|
name: nil,
|
|
422
461
|
logger: nil,
|
|
423
|
-
oauth_provider: nil
|
|
462
|
+
oauth_provider: nil,
|
|
463
|
+
faraday_config: nil
|
|
424
464
|
}
|
|
425
465
|
end
|
|
426
466
|
|