ruby-mcp-client 1.0.1 → 2.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 +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Self-contained JSON Schema validator used to check a tool call result's
|
|
5
|
+
# structuredContent against the tool's declared outputSchema (MCP 2025-11-25
|
|
6
|
+
# server/tools spec: "Clients SHOULD validate structured results against this
|
|
7
|
+
# schema"; the default schema dialect is JSON Schema 2020-12 per SEP-1613).
|
|
8
|
+
#
|
|
9
|
+
# Only the common JSON Schema keywords are supported:
|
|
10
|
+
# - type (single value or array of values), enum, const
|
|
11
|
+
# - properties, required (objects)
|
|
12
|
+
# - items, minItems, maxItems (arrays)
|
|
13
|
+
# - minLength, maxLength, pattern (strings)
|
|
14
|
+
# - minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)
|
|
15
|
+
#
|
|
16
|
+
# The full JSON Schema 2020-12 vocabulary ($ref/$defs, allOf/anyOf/oneOf/not,
|
|
17
|
+
# conditional keywords, additionalProperties, format assertions, ...) is out
|
|
18
|
+
# of scope: unrecognized keywords are ignored rather than misapplied, so
|
|
19
|
+
# validation is best-effort — it may accept data a full validator would
|
|
20
|
+
# reject, but it does not reject data that conforms to the schema. So that
|
|
21
|
+
# this gap is never silent, {.unsupported_keywords} reports which unapplied
|
|
22
|
+
# validation keywords a schema uses; callers surface them as a warning.
|
|
23
|
+
module SchemaValidator
|
|
24
|
+
# JSON Schema 2020-12 keywords that affect validation but that this
|
|
25
|
+
# validator does not evaluate: applicator/reference keywords, assertion
|
|
26
|
+
# keywords (multipleOf, uniqueItems, contains bounds, property-count
|
|
27
|
+
# bounds, dependentRequired), and format (asserted by full validators in
|
|
28
|
+
# format-assertion mode). Their presence means validation is partial: data
|
|
29
|
+
# may pass here that a full validator would reject.
|
|
30
|
+
UNSUPPORTED_KEYWORDS = %w[
|
|
31
|
+
$ref $dynamicRef $defs allOf anyOf oneOf not if then else
|
|
32
|
+
additionalProperties patternProperties propertyNames dependentSchemas
|
|
33
|
+
prefixItems contains minContains maxContains uniqueItems
|
|
34
|
+
multipleOf format dependentRequired minProperties maxProperties
|
|
35
|
+
unevaluatedProperties unevaluatedItems
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Keywords whose value is a single subschema to walk.
|
|
39
|
+
SUBSCHEMA_KEYWORDS = %w[
|
|
40
|
+
items contains additionalProperties propertyNames not if then else
|
|
41
|
+
unevaluatedItems unevaluatedProperties
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# Keywords whose value is a map of name => subschema.
|
|
45
|
+
SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
|
|
46
|
+
|
|
47
|
+
# Keywords whose value is an array of subschemas.
|
|
48
|
+
SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
|
|
49
|
+
|
|
50
|
+
# List the unsupported JSON Schema keywords a schema uses (anywhere: at the
|
|
51
|
+
# top level or nested in subschemas). Property names that merely look like
|
|
52
|
+
# keywords (e.g. a property called 'not') are not reported, and
|
|
53
|
+
# data-carrying keywords (enum/const/default/examples) are not scanned.
|
|
54
|
+
# @param schema [Object] the JSON schema (string or symbol keys)
|
|
55
|
+
# @return [Array<String>] unique unsupported keywords, in discovery order
|
|
56
|
+
def self.unsupported_keywords(schema)
|
|
57
|
+
found = []
|
|
58
|
+
collect_unsupported_keywords(schema, found)
|
|
59
|
+
found.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Recursively collect unsupported keywords from a schema.
|
|
63
|
+
# @param schema [Object] a (sub)schema; non-Hash values are ignored
|
|
64
|
+
# @param found [Array<String>] accumulator
|
|
65
|
+
# @return [void]
|
|
66
|
+
def self.collect_unsupported_keywords(schema, found)
|
|
67
|
+
return unless schema.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
schema = schema.transform_keys(&:to_s)
|
|
70
|
+
found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
|
|
71
|
+
schema.each do |keyword, value|
|
|
72
|
+
if SUBSCHEMA_KEYWORDS.include?(keyword)
|
|
73
|
+
collect_unsupported_keywords(value, found)
|
|
74
|
+
elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
|
|
75
|
+
value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
|
|
76
|
+
elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
|
|
77
|
+
value.each { |subschema| collect_unsupported_keywords(subschema, found) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validate data against a JSON Schema subset.
|
|
83
|
+
# Schema and data hashes may use string or symbol keys.
|
|
84
|
+
# @param data [Object] the value to validate
|
|
85
|
+
# @param schema [Hash] the JSON schema
|
|
86
|
+
# @param path [String] JSON-pointer-style location used in error messages
|
|
87
|
+
# @return [Array<String>] human-readable validation errors (empty if valid)
|
|
88
|
+
def self.validate(data, schema, path: '#')
|
|
89
|
+
return [] unless schema.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
schema = schema.transform_keys(&:to_s)
|
|
92
|
+
errors = []
|
|
93
|
+
errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
|
|
94
|
+
errors.concat(validate_enum(data, schema, path))
|
|
95
|
+
case data
|
|
96
|
+
when Hash then errors.concat(validate_object(data, schema, path))
|
|
97
|
+
when Array then errors.concat(validate_array(data, schema, path))
|
|
98
|
+
when String then errors.concat(validate_string(data, schema, path))
|
|
99
|
+
when Numeric then errors.concat(validate_number(data, schema, path))
|
|
100
|
+
end
|
|
101
|
+
errors
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Validate the JSON type of a value.
|
|
105
|
+
# @param data [Object] the value
|
|
106
|
+
# @param type [String, Symbol, Array<String, Symbol>] expected type(s)
|
|
107
|
+
# @param path [String] location for error messages
|
|
108
|
+
# @return [Array<String>] validation errors
|
|
109
|
+
def self.validate_type(data, type, path)
|
|
110
|
+
types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
|
|
111
|
+
return [] if types.any? { |t| type_match?(t, data) }
|
|
112
|
+
|
|
113
|
+
["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether a value matches a JSON Schema type name.
|
|
117
|
+
# Unknown type names are not enforced (returns true).
|
|
118
|
+
# @param type [String] the JSON Schema type name
|
|
119
|
+
# @param data [Object] the value
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def self.type_match?(type, data)
|
|
122
|
+
case type
|
|
123
|
+
when 'object' then data.is_a?(Hash)
|
|
124
|
+
when 'array' then data.is_a?(Array)
|
|
125
|
+
when 'string' then data.is_a?(String)
|
|
126
|
+
when 'boolean' then data.equal?(true) || data.equal?(false)
|
|
127
|
+
when 'null' then data.nil?
|
|
128
|
+
when 'number' then data.is_a?(Numeric)
|
|
129
|
+
when 'integer' then integer?(data)
|
|
130
|
+
else true
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a
|
|
135
|
+
# number with a zero fractional part (e.g. 2.0) is a valid integer.
|
|
136
|
+
# @param data [Object] the value
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def self.integer?(data)
|
|
139
|
+
return true if data.is_a?(Integer)
|
|
140
|
+
return false unless data.is_a?(Numeric)
|
|
141
|
+
|
|
142
|
+
(data % 1).zero?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The JSON type name of a Ruby value (for error messages).
|
|
146
|
+
# @param data [Object] the value
|
|
147
|
+
# @return [String]
|
|
148
|
+
def self.json_type(data)
|
|
149
|
+
case data
|
|
150
|
+
when nil then 'null'
|
|
151
|
+
when true, false then 'boolean'
|
|
152
|
+
when Integer then 'integer'
|
|
153
|
+
when Numeric then 'number'
|
|
154
|
+
when String then 'string'
|
|
155
|
+
when Array then 'array'
|
|
156
|
+
when Hash then 'object'
|
|
157
|
+
else data.class.name
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Validate enum/const membership.
|
|
162
|
+
# @param data [Object] the value
|
|
163
|
+
# @param schema [Hash] string-keyed schema
|
|
164
|
+
# @param path [String] location for error messages
|
|
165
|
+
# @return [Array<String>] validation errors
|
|
166
|
+
def self.validate_enum(data, schema, path)
|
|
167
|
+
errors = []
|
|
168
|
+
if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
|
|
169
|
+
errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
|
|
170
|
+
end
|
|
171
|
+
if schema.key?('const') && schema['const'] != data
|
|
172
|
+
errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
|
|
173
|
+
end
|
|
174
|
+
errors
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Validate an object against required/properties.
|
|
178
|
+
# @param data [Hash] the object
|
|
179
|
+
# @param schema [Hash] string-keyed schema
|
|
180
|
+
# @param path [String] location for error messages
|
|
181
|
+
# @return [Array<String>] validation errors
|
|
182
|
+
def self.validate_object(data, schema, path)
|
|
183
|
+
errors = []
|
|
184
|
+
Array(schema['required']).each do |raw_name|
|
|
185
|
+
name = raw_name.to_s
|
|
186
|
+
errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
|
|
187
|
+
end
|
|
188
|
+
properties = schema['properties']
|
|
189
|
+
return errors unless properties.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
properties.each do |raw_name, prop_schema|
|
|
192
|
+
next unless prop_schema.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
name = raw_name.to_s
|
|
195
|
+
key = if data.key?(name)
|
|
196
|
+
name
|
|
197
|
+
elsif data.key?(name.to_sym)
|
|
198
|
+
name.to_sym
|
|
199
|
+
end
|
|
200
|
+
next if key.nil?
|
|
201
|
+
|
|
202
|
+
errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}"))
|
|
203
|
+
end
|
|
204
|
+
errors
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Validate an array against items/minItems/maxItems.
|
|
208
|
+
# @param data [Array] the array
|
|
209
|
+
# @param schema [Hash] string-keyed schema
|
|
210
|
+
# @param path [String] location for error messages
|
|
211
|
+
# @return [Array<String>] validation errors
|
|
212
|
+
def self.validate_array(data, schema, path)
|
|
213
|
+
errors = []
|
|
214
|
+
min_items = schema['minItems']
|
|
215
|
+
max_items = schema['maxItems']
|
|
216
|
+
if min_items.is_a?(Numeric) && data.length < min_items
|
|
217
|
+
errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
|
|
218
|
+
end
|
|
219
|
+
if max_items.is_a?(Numeric) && data.length > max_items
|
|
220
|
+
errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
|
|
221
|
+
end
|
|
222
|
+
items = schema['items']
|
|
223
|
+
if items.is_a?(Hash)
|
|
224
|
+
data.each_with_index { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) }
|
|
225
|
+
end
|
|
226
|
+
errors
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Validate a string against minLength/maxLength/pattern.
|
|
230
|
+
# @param data [String] the string
|
|
231
|
+
# @param schema [Hash] string-keyed schema
|
|
232
|
+
# @param path [String] location for error messages
|
|
233
|
+
# @return [Array<String>] validation errors
|
|
234
|
+
def self.validate_string(data, schema, path)
|
|
235
|
+
errors = []
|
|
236
|
+
min_length = schema['minLength']
|
|
237
|
+
max_length = schema['maxLength']
|
|
238
|
+
if min_length.is_a?(Numeric) && data.length < min_length
|
|
239
|
+
errors << "#{path}: string is shorter than minLength #{min_length}"
|
|
240
|
+
end
|
|
241
|
+
if max_length.is_a?(Numeric) && data.length > max_length
|
|
242
|
+
errors << "#{path}: string is longer than maxLength #{max_length}"
|
|
243
|
+
end
|
|
244
|
+
errors.concat(validate_pattern(data, schema['pattern'], path))
|
|
245
|
+
errors
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Validate a string against a regular-expression pattern.
|
|
249
|
+
# Invalid patterns are not enforced.
|
|
250
|
+
# @param data [String] the string
|
|
251
|
+
# @param pattern [Object] the pattern keyword value
|
|
252
|
+
# @param path [String] location for error messages
|
|
253
|
+
# @return [Array<String>] validation errors
|
|
254
|
+
def self.validate_pattern(data, pattern, path)
|
|
255
|
+
return [] unless pattern.is_a?(String)
|
|
256
|
+
return [] if data.match?(Regexp.new(pattern))
|
|
257
|
+
|
|
258
|
+
["#{path}: string does not match pattern #{pattern.inspect}"]
|
|
259
|
+
rescue RegexpError
|
|
260
|
+
[]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Validate a number against inclusive/exclusive bounds.
|
|
264
|
+
# @param data [Numeric] the number
|
|
265
|
+
# @param schema [Hash] string-keyed schema
|
|
266
|
+
# @param path [String] location for error messages
|
|
267
|
+
# @return [Array<String>] validation errors
|
|
268
|
+
def self.validate_number(data, schema, path)
|
|
269
|
+
errors = []
|
|
270
|
+
minimum = schema['minimum']
|
|
271
|
+
maximum = schema['maximum']
|
|
272
|
+
exclusive_min = schema['exclusiveMinimum']
|
|
273
|
+
exclusive_max = schema['exclusiveMaximum']
|
|
274
|
+
errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
|
|
275
|
+
errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
|
|
276
|
+
if exclusive_min.is_a?(Numeric) && data <= exclusive_min
|
|
277
|
+
errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
|
|
278
|
+
end
|
|
279
|
+
if exclusive_max.is_a?(Numeric) && data >= exclusive_max
|
|
280
|
+
errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
|
|
281
|
+
end
|
|
282
|
+
errors
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -9,6 +9,22 @@ module MCPClient
|
|
|
9
9
|
|
|
10
10
|
# Initialize the server with a name
|
|
11
11
|
# @param name [String, nil] server name
|
|
12
|
+
# Server-declared instructions from the initialize result, if any
|
|
13
|
+
# @return [String, nil]
|
|
14
|
+
attr_reader :instructions
|
|
15
|
+
|
|
16
|
+
# Host-supplied Implementation info sent as clientInfo during initialize
|
|
17
|
+
# (MCP 2025-11-25 Implementation: name, version, plus optional title,
|
|
18
|
+
# description, websiteUrl, icons). Defaults to the gem's identity.
|
|
19
|
+
# @param info [Hash] implementation info; must include name and version
|
|
20
|
+
# @raise [ArgumentError] when name or version is missing
|
|
21
|
+
def client_info=(info)
|
|
22
|
+
raise ArgumentError, 'client_info must include name' unless info['name'] || info[:name]
|
|
23
|
+
raise ArgumentError, 'client_info must include version' unless info['version'] || info[:version]
|
|
24
|
+
|
|
25
|
+
@client_info = info.transform_keys(&:to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
12
28
|
def initialize(name: nil)
|
|
13
29
|
@name = name
|
|
14
30
|
end
|
|
@@ -83,11 +99,61 @@ module MCPClient
|
|
|
83
99
|
end
|
|
84
100
|
|
|
85
101
|
# Get server capabilities
|
|
102
|
+
# MCP 2025-11-25 tasks: all messages related to a task MUST carry the
|
|
103
|
+
# io.modelcontextprotocol/related-task key in _meta. Reserved key name:
|
|
104
|
+
RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'
|
|
105
|
+
|
|
106
|
+
# Echo the related-task _meta of an incoming server request onto the
|
|
107
|
+
# outgoing result, so responses to task-related requests (elicitation or
|
|
108
|
+
# sampling during input_required) stay associated with their task.
|
|
109
|
+
# @param result [Hash] the outgoing JSON-RPC result payload
|
|
110
|
+
# @param params [Hash, nil] the incoming request params
|
|
111
|
+
# @return [Hash] result with related-task _meta merged when applicable
|
|
112
|
+
def merge_related_task_meta(result, params)
|
|
113
|
+
related = params.is_a?(Hash) ? params.dig('_meta', RELATED_TASK_META_KEY) : nil
|
|
114
|
+
return result unless related && result.is_a?(Hash) && !result.key?('error')
|
|
115
|
+
|
|
116
|
+
meta = (result['_meta'] || {}).merge(RELATED_TASK_META_KEY => related)
|
|
117
|
+
result.merge('_meta' => meta)
|
|
118
|
+
end
|
|
119
|
+
|
|
86
120
|
# @return [Hash, nil] server capabilities
|
|
87
121
|
def capabilities
|
|
88
122
|
raise NotImplementedError, 'Subclasses must implement capabilities'
|
|
89
123
|
end
|
|
90
124
|
|
|
125
|
+
# Whether the server declared the given (possibly nested) capability
|
|
126
|
+
# during initialization.
|
|
127
|
+
# @param path [Array<String, Symbol>] capability key path, e.g. 'logging'
|
|
128
|
+
# or 'resources', 'subscribe'
|
|
129
|
+
# @return [Boolean]
|
|
130
|
+
def capability?(*path)
|
|
131
|
+
node = begin
|
|
132
|
+
capabilities
|
|
133
|
+
rescue NotImplementedError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
path.each do |key|
|
|
137
|
+
return false unless node.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
node = node[key.to_s]
|
|
140
|
+
end
|
|
141
|
+
!node.nil? && node != false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Raise unless the server negotiated the given capability (MCP lifecycle:
|
|
145
|
+
# "Only use capabilities that were successfully negotiated").
|
|
146
|
+
# @param path [Array<String, Symbol>] capability key path
|
|
147
|
+
# @param method [String] the JSON-RPC method the caller wants to send
|
|
148
|
+
# @raise [MCPClient::Errors::CapabilityError]
|
|
149
|
+
def require_capability!(*path, method:)
|
|
150
|
+
return if capability?(*path)
|
|
151
|
+
|
|
152
|
+
raise MCPClient::Errors::CapabilityError,
|
|
153
|
+
"Server #{name || self.class.name} did not declare the #{path.join('.')} capability " \
|
|
154
|
+
"required for #{method}"
|
|
155
|
+
end
|
|
156
|
+
|
|
91
157
|
# Clean up the server connection
|
|
92
158
|
def cleanup
|
|
93
159
|
raise NotImplementedError, 'Subclasses must implement cleanup'
|
|
@@ -133,8 +199,81 @@ module MCPClient
|
|
|
133
199
|
@notification_callback = block
|
|
134
200
|
end
|
|
135
201
|
|
|
202
|
+
# Safety bound on the number of pages followed when auto-paginating a
|
|
203
|
+
# cursor-based list operation, to protect against a server that returns
|
|
204
|
+
# a nextCursor indefinitely.
|
|
205
|
+
MAX_LIST_PAGES = 1000
|
|
206
|
+
|
|
136
207
|
protected
|
|
137
208
|
|
|
209
|
+
# Follow cursor-based pagination across pages, collecting every item.
|
|
210
|
+
#
|
|
211
|
+
# Yields the current cursor (nil for the first page) and expects the block
|
|
212
|
+
# to return a two-element array: [items_for_this_page, next_cursor]. The
|
|
213
|
+
# loop stops when next_cursor is nil or empty, when a cursor repeats
|
|
214
|
+
# (malformed server), or when MAX_LIST_PAGES pages have been fetched.
|
|
215
|
+
#
|
|
216
|
+
# @param kind [String] label used in diagnostic log messages
|
|
217
|
+
# @yieldparam cursor [String, nil] cursor for the page to fetch
|
|
218
|
+
# @yieldreturn [Array(Array, String), Array(Array, nil)] page items and next cursor
|
|
219
|
+
# @return [Array] all items collected across pages
|
|
220
|
+
def collect_paginated(kind = 'items')
|
|
221
|
+
items = []
|
|
222
|
+
cursor = nil
|
|
223
|
+
seen_cursors = {}
|
|
224
|
+
pages = 0
|
|
225
|
+
|
|
226
|
+
loop do
|
|
227
|
+
page_items, next_cursor = yield(cursor)
|
|
228
|
+
items.concat(Array(page_items))
|
|
229
|
+
pages += 1
|
|
230
|
+
|
|
231
|
+
break if next_cursor.nil? || next_cursor.to_s.empty?
|
|
232
|
+
|
|
233
|
+
if seen_cursors[next_cursor]
|
|
234
|
+
@logger.warn("Pagination for #{kind} stopped: server returned a repeated cursor #{next_cursor.inspect}")
|
|
235
|
+
break
|
|
236
|
+
end
|
|
237
|
+
if pages >= MAX_LIST_PAGES
|
|
238
|
+
@logger.warn("Pagination for #{kind} stopped after #{pages} pages (safety bound reached)")
|
|
239
|
+
break
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
seen_cursors[next_cursor] = true
|
|
243
|
+
cursor = next_cursor
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
items
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Fetch a full, cursor-paginated list result via rpc_request, following
|
|
250
|
+
# nextCursor across pages until the server stops returning one.
|
|
251
|
+
#
|
|
252
|
+
# Accepts either a spec-shaped Hash ({ key => [...], 'nextCursor' => ... })
|
|
253
|
+
# or, leniently, a bare Array (a single unpaginated page). A response that
|
|
254
|
+
# is neither (e.g. a null/missing result or a scalar) is a malformed list
|
|
255
|
+
# response and raises, rather than being silently treated as an empty list.
|
|
256
|
+
#
|
|
257
|
+
# @param method [String] the list method, e.g. 'tools/list'
|
|
258
|
+
# @param key [String] the result array key, e.g. 'tools'
|
|
259
|
+
# @return [Array<Hash>] all raw item hashes collected across pages
|
|
260
|
+
# @raise [MCPClient::Errors::TransportError] if a page result is not a Hash or Array
|
|
261
|
+
def request_paginated_list(method, key)
|
|
262
|
+
collect_paginated(key) do |cursor|
|
|
263
|
+
params = cursor ? { cursor: cursor } : {}
|
|
264
|
+
result = rpc_request(method, params)
|
|
265
|
+
case result
|
|
266
|
+
when Hash
|
|
267
|
+
[result[key] || [], result['nextCursor']]
|
|
268
|
+
when Array
|
|
269
|
+
[result, nil]
|
|
270
|
+
else
|
|
271
|
+
raise MCPClient::Errors::TransportError,
|
|
272
|
+
"Invalid #{method} response: expected an object or array, got #{result.class}"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
138
277
|
# Initialize logger with proper formatter handling
|
|
139
278
|
# Preserves custom formatter if logger is provided, otherwise sets a default formatter
|
|
140
279
|
# @param logger [Logger, nil] custom logger to use, or nil to create a default one
|
|
@@ -12,10 +12,11 @@ module MCPClient
|
|
|
12
12
|
|
|
13
13
|
# Parse an HTTP JSON-RPC response
|
|
14
14
|
# @param response [Faraday::Response] the HTTP response
|
|
15
|
+
# @param _request [Hash, nil] the originating JSON-RPC request (unused)
|
|
15
16
|
# @return [Hash] the parsed result
|
|
16
17
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
17
18
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
18
|
-
def parse_response(response)
|
|
19
|
+
def parse_response(response, _request = nil)
|
|
19
20
|
body = response.body.strip
|
|
20
21
|
data = JSON.parse(body)
|
|
21
22
|
process_jsonrpc_response(data)
|
|
@@ -183,10 +183,7 @@ module MCPClient
|
|
|
183
183
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
|
184
184
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
185
185
|
def call_tool(tool_name, parameters)
|
|
186
|
-
rpc_request('tools/call',
|
|
187
|
-
name: tool_name,
|
|
188
|
-
arguments: parameters
|
|
189
|
-
})
|
|
186
|
+
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
190
187
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
191
188
|
# Re-raise connection/transport errors directly to match test expectations
|
|
192
189
|
raise
|
|
@@ -246,8 +243,8 @@ module MCPClient
|
|
|
246
243
|
begin
|
|
247
244
|
ensure_connected
|
|
248
245
|
|
|
249
|
-
|
|
250
|
-
prompts =
|
|
246
|
+
# Follow nextCursor across pages so the full prompt list is returned.
|
|
247
|
+
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
251
248
|
|
|
252
249
|
@mutex.synchronize do
|
|
253
250
|
@prompts = prompts.map do |prompt_data|
|
|
@@ -271,10 +268,7 @@ module MCPClient
|
|
|
271
268
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
272
269
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
|
|
273
270
|
def get_prompt(prompt_name, parameters)
|
|
274
|
-
rpc_request('prompts/get',
|
|
275
|
-
name: prompt_name,
|
|
276
|
-
arguments: parameters
|
|
277
|
-
})
|
|
271
|
+
rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
|
|
278
272
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
279
273
|
raise
|
|
280
274
|
rescue StandardError => e
|
|
@@ -336,11 +330,14 @@ module MCPClient
|
|
|
336
330
|
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
337
331
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
338
332
|
def complete(ref:, argument:, context: nil)
|
|
333
|
+
ensure_connected
|
|
334
|
+
require_capability!('completions', method: 'completion/complete')
|
|
339
335
|
params = { ref: ref, argument: argument }
|
|
340
336
|
params[:context] = context if context
|
|
341
337
|
result = rpc_request('completion/complete', params)
|
|
342
338
|
result['completion'] || { 'values' => [] }
|
|
343
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
339
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
340
|
+
MCPClient::Errors::CapabilityError
|
|
344
341
|
raise
|
|
345
342
|
rescue StandardError => e
|
|
346
343
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
@@ -352,8 +349,11 @@ module MCPClient
|
|
|
352
349
|
# @return [Hash] empty result on success
|
|
353
350
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
354
351
|
def log_level=(level)
|
|
352
|
+
ensure_connected
|
|
353
|
+
require_capability!('logging', method: 'logging/setLevel')
|
|
355
354
|
rpc_request('logging/setLevel', { level: level })
|
|
356
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
355
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
356
|
+
MCPClient::Errors::CapabilityError
|
|
357
357
|
raise
|
|
358
358
|
rescue StandardError => e
|
|
359
359
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
@@ -384,9 +384,12 @@ module MCPClient
|
|
|
384
384
|
# @return [Boolean] true if subscription successful
|
|
385
385
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
386
386
|
def subscribe_resource(uri)
|
|
387
|
+
ensure_connected
|
|
388
|
+
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
387
389
|
rpc_request('resources/subscribe', { uri: uri })
|
|
388
390
|
true
|
|
389
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
391
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
392
|
+
MCPClient::Errors::CapabilityError
|
|
390
393
|
raise
|
|
391
394
|
rescue StandardError => e
|
|
392
395
|
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
@@ -397,9 +400,12 @@ module MCPClient
|
|
|
397
400
|
# @return [Boolean] true if unsubscription successful
|
|
398
401
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
399
402
|
def unsubscribe_resource(uri)
|
|
403
|
+
ensure_connected
|
|
404
|
+
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
400
405
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
401
406
|
true
|
|
402
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
407
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
408
|
+
MCPClient::Errors::CapabilityError
|
|
403
409
|
raise
|
|
404
410
|
rescue StandardError => e
|
|
405
411
|
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
@@ -448,6 +454,25 @@ module MCPClient
|
|
|
448
454
|
|
|
449
455
|
private
|
|
450
456
|
|
|
457
|
+
# Perform the MCP initialize handshake, then announce readiness.
|
|
458
|
+
#
|
|
459
|
+
# The base handshake sends the initialize request and captures
|
|
460
|
+
# serverInfo/capabilities. Per the MCP lifecycle the client MUST send an
|
|
461
|
+
# `initialized` notification after a successful initialize before issuing
|
|
462
|
+
# any other requests; the plain HTTP transport previously skipped it.
|
|
463
|
+
# @return [void]
|
|
464
|
+
# @raise [MCPClient::Errors::TransportError] if the notification fails to send
|
|
465
|
+
def perform_initialize
|
|
466
|
+
super
|
|
467
|
+
|
|
468
|
+
notification = build_jsonrpc_notification('notifications/initialized', {})
|
|
469
|
+
begin
|
|
470
|
+
send_http_request(notification)
|
|
471
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
|
|
472
|
+
raise MCPClient::Errors::TransportError, "Failed to send initialized notification: #{e.message}"
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
451
476
|
# Default options for server initialization
|
|
452
477
|
# @return [Hash] Default options
|
|
453
478
|
def default_options
|
|
@@ -497,24 +522,15 @@ module MCPClient
|
|
|
497
522
|
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
|
498
523
|
def request_tools_list
|
|
499
524
|
@mutex.synchronize do
|
|
500
|
-
return @tools_data if @tools_data
|
|
525
|
+
return @tools_data.dup if @tools_data
|
|
501
526
|
end
|
|
502
527
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
@mutex.synchronize do
|
|
507
|
-
@tools_data = result['tools']
|
|
508
|
-
end
|
|
509
|
-
return @mutex.synchronize { @tools_data.dup }
|
|
510
|
-
elsif result
|
|
511
|
-
@mutex.synchronize do
|
|
512
|
-
@tools_data = result
|
|
513
|
-
end
|
|
514
|
-
return @mutex.synchronize { @tools_data.dup }
|
|
515
|
-
end
|
|
528
|
+
# Follow nextCursor across pages so the full tool list is returned even
|
|
529
|
+
# when the server paginates.
|
|
530
|
+
tools = request_paginated_list('tools/list', 'tools')
|
|
516
531
|
|
|
517
|
-
|
|
532
|
+
@mutex.synchronize { @tools_data = tools }
|
|
533
|
+
@mutex.synchronize { @tools_data.dup }
|
|
518
534
|
end
|
|
519
535
|
end
|
|
520
536
|
end
|