ruby-mcp-client 1.1.0 → 2.1.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 +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- metadata +5 -6
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
# Wall-clock budget for ALL pattern matching in a single validate call.
|
|
39
|
+
# Schemas come from the remote server, so an expensive expression must not
|
|
40
|
+
# be able to monopolize the calling thread.
|
|
41
|
+
#
|
|
42
|
+
# The budget is for the whole operation, not per match: a per-match limit
|
|
43
|
+
# multiplies, since the server also controls how many strings it sends
|
|
44
|
+
# (N array items under one pathological items.pattern costs N x limit).
|
|
45
|
+
PATTERN_MATCH_TIMEOUT = 1.0
|
|
46
|
+
|
|
47
|
+
# Floor for an individual match's timeout, so a nearly-exhausted budget
|
|
48
|
+
# still makes progress rather than failing every remaining pattern.
|
|
49
|
+
MIN_PATTERN_MATCH_TIMEOUT = 0.01
|
|
50
|
+
|
|
51
|
+
# Keywords whose value is a single subschema to walk.
|
|
52
|
+
SUBSCHEMA_KEYWORDS = %w[
|
|
53
|
+
items contains additionalProperties propertyNames not if then else
|
|
54
|
+
unevaluatedItems unevaluatedProperties
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Keywords whose value is a map of name => subschema.
|
|
58
|
+
SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
|
|
59
|
+
|
|
60
|
+
# Keywords whose value is an array of subschemas.
|
|
61
|
+
SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
|
|
62
|
+
|
|
63
|
+
# List the unsupported JSON Schema keywords a schema uses (anywhere: at the
|
|
64
|
+
# top level or nested in subschemas). Property names that merely look like
|
|
65
|
+
# keywords (e.g. a property called 'not') are not reported, and
|
|
66
|
+
# data-carrying keywords (enum/const/default/examples) are not scanned.
|
|
67
|
+
# @param schema [Object] the JSON schema (string or symbol keys)
|
|
68
|
+
# @return [Array<String>] unique unsupported keywords, in discovery order
|
|
69
|
+
def self.unsupported_keywords(schema)
|
|
70
|
+
found = []
|
|
71
|
+
collect_unsupported_keywords(schema, found)
|
|
72
|
+
found.uniq
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Recursively collect unsupported keywords from a schema.
|
|
76
|
+
# @param schema [Object] a (sub)schema; non-Hash values are ignored
|
|
77
|
+
# @param found [Array<String>] accumulator
|
|
78
|
+
# @return [void]
|
|
79
|
+
def self.collect_unsupported_keywords(schema, found)
|
|
80
|
+
return unless schema.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
schema = schema.transform_keys(&:to_s)
|
|
83
|
+
found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
|
|
84
|
+
schema.each do |keyword, value|
|
|
85
|
+
if SUBSCHEMA_KEYWORDS.include?(keyword)
|
|
86
|
+
collect_unsupported_keywords(value, found)
|
|
87
|
+
elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
|
|
88
|
+
value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
|
|
89
|
+
elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
|
|
90
|
+
value.each { |subschema| collect_unsupported_keywords(subschema, found) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Validate data against a JSON Schema subset.
|
|
96
|
+
# Schema and data hashes may use string or symbol keys.
|
|
97
|
+
# @param data [Object] the value to validate
|
|
98
|
+
# @param schema [Hash] the JSON schema
|
|
99
|
+
# @param path [String] JSON-pointer-style location used in error messages
|
|
100
|
+
# @return [Array<String>] human-readable validation errors (empty if valid)
|
|
101
|
+
def self.validate(data, schema, path: '#', deadline: nil)
|
|
102
|
+
return [] unless schema.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
# One deadline covers the entire (recursive) validation.
|
|
105
|
+
deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
|
|
106
|
+
|
|
107
|
+
schema = schema.transform_keys(&:to_s)
|
|
108
|
+
errors = []
|
|
109
|
+
errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
|
|
110
|
+
errors.concat(validate_enum(data, schema, path))
|
|
111
|
+
case data
|
|
112
|
+
when Hash then errors.concat(validate_object(data, schema, path, deadline))
|
|
113
|
+
when Array then errors.concat(validate_array(data, schema, path, deadline))
|
|
114
|
+
when String then errors.concat(validate_string(data, schema, path, deadline))
|
|
115
|
+
when Numeric then errors.concat(validate_number(data, schema, path))
|
|
116
|
+
end
|
|
117
|
+
errors
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validate the JSON type of a value.
|
|
121
|
+
# @param data [Object] the value
|
|
122
|
+
# @param type [String, Symbol, Array<String, Symbol>] expected type(s)
|
|
123
|
+
# @param path [String] location for error messages
|
|
124
|
+
# @return [Array<String>] validation errors
|
|
125
|
+
def self.validate_type(data, type, path)
|
|
126
|
+
types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
|
|
127
|
+
return [] if types.any? { |t| type_match?(t, data) }
|
|
128
|
+
|
|
129
|
+
["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Whether a value matches a JSON Schema type name.
|
|
133
|
+
# Unknown type names are not enforced (returns true).
|
|
134
|
+
# @param type [String] the JSON Schema type name
|
|
135
|
+
# @param data [Object] the value
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def self.type_match?(type, data)
|
|
138
|
+
case type
|
|
139
|
+
when 'object' then data.is_a?(Hash)
|
|
140
|
+
when 'array' then data.is_a?(Array)
|
|
141
|
+
when 'string' then data.is_a?(String)
|
|
142
|
+
when 'boolean' then data.equal?(true) || data.equal?(false)
|
|
143
|
+
when 'null' then data.nil?
|
|
144
|
+
when 'number' then data.is_a?(Numeric)
|
|
145
|
+
when 'integer' then integer?(data)
|
|
146
|
+
else true
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a
|
|
151
|
+
# number with a zero fractional part (e.g. 2.0) is a valid integer.
|
|
152
|
+
# @param data [Object] the value
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def self.integer?(data)
|
|
155
|
+
return true if data.is_a?(Integer)
|
|
156
|
+
return false unless data.is_a?(Numeric)
|
|
157
|
+
|
|
158
|
+
(data % 1).zero?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The JSON type name of a Ruby value (for error messages).
|
|
162
|
+
# @param data [Object] the value
|
|
163
|
+
# @return [String]
|
|
164
|
+
def self.json_type(data)
|
|
165
|
+
case data
|
|
166
|
+
when nil then 'null'
|
|
167
|
+
when true, false then 'boolean'
|
|
168
|
+
when Integer then 'integer'
|
|
169
|
+
when Numeric then 'number'
|
|
170
|
+
when String then 'string'
|
|
171
|
+
when Array then 'array'
|
|
172
|
+
when Hash then 'object'
|
|
173
|
+
else data.class.name
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Validate enum/const membership.
|
|
178
|
+
# @param data [Object] the value
|
|
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_enum(data, schema, path)
|
|
183
|
+
errors = []
|
|
184
|
+
if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
|
|
185
|
+
errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
|
|
186
|
+
end
|
|
187
|
+
if schema.key?('const') && schema['const'] != data
|
|
188
|
+
errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
|
|
189
|
+
end
|
|
190
|
+
errors
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Validate an object against required/properties.
|
|
194
|
+
# @param data [Hash] the object
|
|
195
|
+
# @param schema [Hash] string-keyed schema
|
|
196
|
+
# @param path [String] location for error messages
|
|
197
|
+
# @return [Array<String>] validation errors
|
|
198
|
+
def self.validate_object(data, schema, path, deadline = nil)
|
|
199
|
+
errors = []
|
|
200
|
+
Array(schema['required']).each do |raw_name|
|
|
201
|
+
name = raw_name.to_s
|
|
202
|
+
errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
|
|
203
|
+
end
|
|
204
|
+
properties = schema['properties']
|
|
205
|
+
return errors unless properties.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
properties.each do |raw_name, prop_schema|
|
|
208
|
+
next unless prop_schema.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
name = raw_name.to_s
|
|
211
|
+
key = if data.key?(name)
|
|
212
|
+
name
|
|
213
|
+
elsif data.key?(name.to_sym)
|
|
214
|
+
name.to_sym
|
|
215
|
+
end
|
|
216
|
+
next if key.nil?
|
|
217
|
+
|
|
218
|
+
errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline))
|
|
219
|
+
end
|
|
220
|
+
errors
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Validate an array against items/minItems/maxItems.
|
|
224
|
+
# @param data [Array] the array
|
|
225
|
+
# @param schema [Hash] string-keyed schema
|
|
226
|
+
# @param path [String] location for error messages
|
|
227
|
+
# @return [Array<String>] validation errors
|
|
228
|
+
def self.validate_array(data, schema, path, deadline = nil)
|
|
229
|
+
errors = []
|
|
230
|
+
min_items = schema['minItems']
|
|
231
|
+
max_items = schema['maxItems']
|
|
232
|
+
if min_items.is_a?(Numeric) && data.length < min_items
|
|
233
|
+
errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
|
|
234
|
+
end
|
|
235
|
+
if max_items.is_a?(Numeric) && data.length > max_items
|
|
236
|
+
errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
|
|
237
|
+
end
|
|
238
|
+
items = schema['items']
|
|
239
|
+
if items.is_a?(Hash)
|
|
240
|
+
data.each_with_index do |item, idx|
|
|
241
|
+
errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
errors
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Validate a string against minLength/maxLength/pattern.
|
|
248
|
+
# @param data [String] the string
|
|
249
|
+
# @param schema [Hash] string-keyed schema
|
|
250
|
+
# @param path [String] location for error messages
|
|
251
|
+
# @return [Array<String>] validation errors
|
|
252
|
+
def self.validate_string(data, schema, path, deadline = nil)
|
|
253
|
+
errors = []
|
|
254
|
+
min_length = schema['minLength']
|
|
255
|
+
max_length = schema['maxLength']
|
|
256
|
+
if min_length.is_a?(Numeric) && data.length < min_length
|
|
257
|
+
errors << "#{path}: string is shorter than minLength #{min_length}"
|
|
258
|
+
end
|
|
259
|
+
if max_length.is_a?(Numeric) && data.length > max_length
|
|
260
|
+
errors << "#{path}: string is longer than maxLength #{max_length}"
|
|
261
|
+
end
|
|
262
|
+
errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
|
|
263
|
+
errors
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Validate a string against a regular-expression pattern.
|
|
267
|
+
# Invalid patterns are not enforced.
|
|
268
|
+
#
|
|
269
|
+
# The pattern comes from the tool's outputSchema, i.e. from the remote
|
|
270
|
+
# server, so matching runs against the validation-wide deadline: neither a
|
|
271
|
+
# single expensive expression nor many cheap-looking ones can pin the
|
|
272
|
+
# calling thread. A match that exceeds the budget is reported as a
|
|
273
|
+
# validation error rather than silently accepted — the value was never
|
|
274
|
+
# shown to satisfy the schema.
|
|
275
|
+
# @param data [String] the string
|
|
276
|
+
# @param pattern [Object] the pattern keyword value
|
|
277
|
+
# @param path [String] location for error messages
|
|
278
|
+
# @param deadline [Float, nil] monotonic deadline for the whole validation
|
|
279
|
+
# @return [Array<String>] validation errors
|
|
280
|
+
def self.validate_pattern(data, pattern, path, deadline = nil)
|
|
281
|
+
return [] unless pattern.is_a?(String)
|
|
282
|
+
|
|
283
|
+
remaining = pattern_budget_remaining(deadline)
|
|
284
|
+
return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero?
|
|
285
|
+
|
|
286
|
+
return [] if data.match?(Regexp.new(pattern, timeout: remaining))
|
|
287
|
+
|
|
288
|
+
["#{path}: string does not match pattern #{pattern.inspect}"]
|
|
289
|
+
rescue Regexp::TimeoutError
|
|
290
|
+
["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
|
|
291
|
+
rescue RegexpError
|
|
292
|
+
[]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Time left in the validation-wide pattern budget.
|
|
296
|
+
# @param deadline [Float, nil] monotonic deadline, or nil for a lone match
|
|
297
|
+
# @return [Float] seconds available for the next match; 0.0 when exhausted
|
|
298
|
+
def self.pattern_budget_remaining(deadline)
|
|
299
|
+
return PATTERN_MATCH_TIMEOUT unless deadline
|
|
300
|
+
|
|
301
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
302
|
+
return 0.0 if remaining <= 0
|
|
303
|
+
|
|
304
|
+
[remaining, MIN_PATTERN_MATCH_TIMEOUT].max
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Validate a number against inclusive/exclusive bounds.
|
|
308
|
+
# @param data [Numeric] the number
|
|
309
|
+
# @param schema [Hash] string-keyed schema
|
|
310
|
+
# @param path [String] location for error messages
|
|
311
|
+
# @return [Array<String>] validation errors
|
|
312
|
+
def self.validate_number(data, schema, path)
|
|
313
|
+
errors = []
|
|
314
|
+
minimum = schema['minimum']
|
|
315
|
+
maximum = schema['maximum']
|
|
316
|
+
exclusive_min = schema['exclusiveMinimum']
|
|
317
|
+
exclusive_max = schema['exclusiveMaximum']
|
|
318
|
+
errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
|
|
319
|
+
errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
|
|
320
|
+
if exclusive_min.is_a?(Numeric) && data <= exclusive_min
|
|
321
|
+
errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
|
|
322
|
+
end
|
|
323
|
+
if exclusive_max.is_a?(Numeric) && data >= exclusive_max
|
|
324
|
+
errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
|
|
325
|
+
end
|
|
326
|
+
errors
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
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'
|
|
@@ -99,7 +99,10 @@ module MCPClient
|
|
|
99
99
|
name: config[:name],
|
|
100
100
|
logger: logger,
|
|
101
101
|
oauth_provider: config[:oauth_provider],
|
|
102
|
-
faraday_config: config[:faraday_config]
|
|
102
|
+
faraday_config: config[:faraday_config],
|
|
103
|
+
max_decompressed_body_bytes:
|
|
104
|
+
config[:max_decompressed_body_bytes] ||
|
|
105
|
+
MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
|
|
103
106
|
)
|
|
104
107
|
end
|
|
105
108
|
|
|
@@ -12,15 +12,16 @@ 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)
|
|
22
23
|
rescue JSON::ParserError => e
|
|
23
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
24
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
end
|
|
@@ -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
|
|
@@ -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}"
|