ruby-mcp-client 2.1.0 → 3.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/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# The keywords that apply to one instance value's own type: the object
|
|
6
|
+
# and array vocabularies, and the bookkeeping they need. Extended into
|
|
7
|
+
# SchemaValidator, so the methods are its own; {Evaluation} calls them
|
|
8
|
+
# once per schema position and {Composition} reads what they left
|
|
9
|
+
# undecided.
|
|
10
|
+
#
|
|
11
|
+
# Everything here is decided by the instance and this schema object
|
|
12
|
+
# alone, which is why it can be evaluated at all. What the keywords
|
|
13
|
+
# evaluate of the value is recorded on the application's {Evaluated} as
|
|
14
|
+
# they go, and `unevaluatedItems` / `unevaluatedProperties` read that
|
|
15
|
+
# record once every applicator has run ({Evaluation#apply_unevaluated}).
|
|
16
|
+
module Instances
|
|
17
|
+
# Validate an object against the keywords that apply to it: `required`,
|
|
18
|
+
# the property-count bounds, the required half of a dependency, the
|
|
19
|
+
# `properties` schemas, and the schemas that decide the members those do
|
|
20
|
+
# not name (`patternProperties`, `additionalProperties`, `propertyNames`).
|
|
21
|
+
# @param data [Hash] the object
|
|
22
|
+
# @param schema [Hash] string-keyed schema
|
|
23
|
+
# @param path [String] location for error messages
|
|
24
|
+
# @param evaluated [Evaluated, nil] where the members the keywords
|
|
25
|
+
# evaluate are recorded
|
|
26
|
+
# @return [Array<String>] validation errors
|
|
27
|
+
def validate_object(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil)
|
|
28
|
+
errors = []
|
|
29
|
+
Array(schema['required']).each do |raw_name|
|
|
30
|
+
name = raw_name.to_s
|
|
31
|
+
errors << "#{path}: missing required property '#{clip(name)}'" unless property_present?(data, name)
|
|
32
|
+
end
|
|
33
|
+
errors.concat(validate_property_counts(data, schema, path))
|
|
34
|
+
errors.concat(validate_dependent_required(data, schema, path, dialect))
|
|
35
|
+
errors.concat(validate_named_properties(data, schema, path, ctx, evaluated))
|
|
36
|
+
errors.concat(validate_other_properties(data, schema, path, ctx, dialect, evaluated))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `unevaluatedProperties` (JSON Schema 2020-12 Core Section 11.3): the
|
|
40
|
+
# keyword's schema applies to every member nothing applied to the
|
|
41
|
+
# value evaluated — not this node's own property keywords, not any
|
|
42
|
+
# applicator that passed. The members are as many as the peer sent, so
|
|
43
|
+
# the sweep consults the deadline as it goes.
|
|
44
|
+
# @param app [Evaluation::Application] the finished application
|
|
45
|
+
# @param sub [Object] the keyword's schema
|
|
46
|
+
# @return [Array<String>] validation errors
|
|
47
|
+
def unevaluated_property_errors(app, sub)
|
|
48
|
+
data = app.data
|
|
49
|
+
ctx = app.ctx
|
|
50
|
+
path = app.path
|
|
51
|
+
data.flat_map do |key, value|
|
|
52
|
+
check_deadline(ctx)
|
|
53
|
+
next [] if app.evaluated.property?(key)
|
|
54
|
+
|
|
55
|
+
name = key.to_s
|
|
56
|
+
unevaluated_errors(value, sub, "#{path}/#{name}", ctx,
|
|
57
|
+
"#{path}: property '#{clip(name)}' is not allowed (unevaluatedProperties is false)")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# What one member or item left unevaluated costs: the keyword's schema
|
|
62
|
+
# applied to it, or — where that schema is `false` — one error.
|
|
63
|
+
# @return [Array<String>] validation errors
|
|
64
|
+
def unevaluated_errors(value, sub, path, ctx, refusal)
|
|
65
|
+
if sub == false
|
|
66
|
+
count_visit(ctx)
|
|
67
|
+
return [refusal]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
validate_child(value, sub, path, ctx)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# minProperties / maxProperties (JSON Schema 2020-12 Validation Sections
|
|
74
|
+
# 6.5.1-6.5.2).
|
|
75
|
+
# @return [Array<String>] validation errors
|
|
76
|
+
def validate_property_counts(data, schema, path)
|
|
77
|
+
errors = []
|
|
78
|
+
min = schema['minProperties']
|
|
79
|
+
max = schema['maxProperties']
|
|
80
|
+
if min.is_a?(Numeric) && data.size < min
|
|
81
|
+
errors << "#{path}: object has #{data.size} properties, fewer than minProperties #{min}"
|
|
82
|
+
end
|
|
83
|
+
if max.is_a?(Numeric) && data.size > max
|
|
84
|
+
errors << "#{path}: object has #{data.size} properties, more than maxProperties #{max}"
|
|
85
|
+
end
|
|
86
|
+
errors
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The required half of a dependency: `dependentRequired` in 2019-09 and
|
|
90
|
+
# 2020-12, and the property-name arrays of draft-07's `dependencies`
|
|
91
|
+
# (JSON Schema 2020-12 Validation Section 6.5.4, draft-07 Section 6.5.7).
|
|
92
|
+
# @return [Array<String>] validation errors
|
|
93
|
+
def validate_dependent_required(data, schema, path, dialect)
|
|
94
|
+
errors = []
|
|
95
|
+
%w[dependentRequired dependencies].each do |keyword|
|
|
96
|
+
map = schema[keyword] if keyword_known?(keyword, dialect)
|
|
97
|
+
next unless map.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
map.each do |trigger, dependents|
|
|
100
|
+
next unless dependents.is_a?(Array) && property_present?(data, trigger)
|
|
101
|
+
|
|
102
|
+
dependents.each do |name|
|
|
103
|
+
next if property_present?(data, name)
|
|
104
|
+
|
|
105
|
+
errors << "#{path}: property '#{clip(trigger.to_s)}' requires property '#{clip(name.to_s)}'"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
errors
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The `properties` schemas, applied in the order the schema names them.
|
|
113
|
+
# Each member the keyword names is evaluated by it (its annotation).
|
|
114
|
+
# @return [Array<String>] validation errors
|
|
115
|
+
def validate_named_properties(data, schema, path, ctx, evaluated = nil)
|
|
116
|
+
properties = schema['properties']
|
|
117
|
+
return [] unless properties.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
errors = []
|
|
120
|
+
properties.each do |raw_name, prop_schema|
|
|
121
|
+
next unless schema_value?(prop_schema)
|
|
122
|
+
|
|
123
|
+
name = raw_name.to_s
|
|
124
|
+
key = data.key?(name) ? name : (name.to_sym if data.key?(name.to_sym))
|
|
125
|
+
next if key.nil?
|
|
126
|
+
|
|
127
|
+
evaluated&.name!(name)
|
|
128
|
+
# A property is a smaller instance, so the hops taken to reach this
|
|
129
|
+
# schema cannot repeat forever below it: the budget counts a chain
|
|
130
|
+
# of references applied to one value, not how deep the data nests.
|
|
131
|
+
# The step down is what the depth bound counts.
|
|
132
|
+
errors.concat(validate_child(data[key], prop_schema, "#{path}/#{name}", ctx))
|
|
133
|
+
end
|
|
134
|
+
errors
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The property applicators that decide members `properties` does not
|
|
138
|
+
# name: every name is checked against `propertyNames`, a member whose
|
|
139
|
+
# name matches a `patternProperties` pattern is validated against each
|
|
140
|
+
# matching schema, and one left over by both goes to
|
|
141
|
+
# `additionalProperties` (JSON Schema 2020-12 Core Sections 10.3.2.1-3).
|
|
142
|
+
# @return [Array<String>] validation errors
|
|
143
|
+
def validate_other_properties(data, schema, path, ctx, dialect, evaluated = nil)
|
|
144
|
+
patterns = schema['patternProperties'] if keyword_known?('patternProperties', dialect)
|
|
145
|
+
patterns = nil unless patterns.is_a?(Hash)
|
|
146
|
+
names = schema_value?(schema['propertyNames']) ? schema['propertyNames'] : nil
|
|
147
|
+
additional = schema['additionalProperties'] if schema_value?(schema['additionalProperties'])
|
|
148
|
+
return [] if patterns.nil? && names.nil? && additional.nil?
|
|
149
|
+
|
|
150
|
+
named = schema['properties'].is_a?(Hash) ? schema['properties'].keys.map(&:to_s) : []
|
|
151
|
+
data.flat_map do |key, value|
|
|
152
|
+
# How wide this sweep is the peer's choice, not the schema's, and a
|
|
153
|
+
# member the applicators decide without descending into it visits no
|
|
154
|
+
# node of its own: the deadline is consulted here so a huge object
|
|
155
|
+
# cannot run the walk past the budget between two nodes it visits.
|
|
156
|
+
check_deadline(ctx)
|
|
157
|
+
property_errors(key.to_s, value, path, ctx, evaluated,
|
|
158
|
+
names: names, patterns: patterns, additional: additional, named: named)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# One member's errors under the property applicators. A member a
|
|
163
|
+
# pattern matched or `additionalProperties` applied to is evaluated by
|
|
164
|
+
# that keyword (its annotation); `propertyNames` evaluates the name,
|
|
165
|
+
# never the member.
|
|
166
|
+
# @return [Array<String>] validation errors
|
|
167
|
+
def property_errors(name, value, path, ctx, evaluated, names:, patterns:, additional:, named:)
|
|
168
|
+
errors = names.nil? ? [] : property_name_errors(name, names, path, ctx)
|
|
169
|
+
matched = false
|
|
170
|
+
patterns&.each do |pattern, sub|
|
|
171
|
+
next unless schema_value?(sub) && pattern_matches?(pattern.to_s, name, ctx.deadline)
|
|
172
|
+
|
|
173
|
+
matched = true
|
|
174
|
+
evaluated&.name!(name)
|
|
175
|
+
errors.concat(validate_child(value, sub, "#{path}/#{name}", ctx))
|
|
176
|
+
end
|
|
177
|
+
return errors if matched || named.include?(name) || additional.nil?
|
|
178
|
+
|
|
179
|
+
# A rejected member costs an error rather than a descent, so it is
|
|
180
|
+
# charged here: the count is what stops a peer-sized object.
|
|
181
|
+
if additional == false
|
|
182
|
+
count_visit(ctx)
|
|
183
|
+
return errors.push("#{path}: property '#{clip(name)}' is not allowed (additionalProperties is false)")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
evaluated&.name!(name)
|
|
187
|
+
errors.concat(validate_child(value, additional, "#{path}/#{name}", ctx))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# `propertyNames` applies its schema to each property *name* (a string),
|
|
191
|
+
# so what it rejects is reported as one error about the name rather than
|
|
192
|
+
# as a type error about a value the instance does not hold there.
|
|
193
|
+
# @return [Array<String>] validation errors
|
|
194
|
+
def property_name_errors(name, sub, path, ctx)
|
|
195
|
+
errors = speculatively(ctx) { validate_child(name, sub, "#{path}/#{name}", ctx) }
|
|
196
|
+
return [] if errors.empty?
|
|
197
|
+
|
|
198
|
+
["#{path}: property name '#{clip(name)}' does not satisfy propertyNames (#{clip(errors.first.to_s)})"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Run a block whose errors are a verdict rather than output, so they are
|
|
202
|
+
# not charged to MAX_ERRORS (only what the caller reports is).
|
|
203
|
+
# @return [Object] the block's value
|
|
204
|
+
def speculatively(ctx)
|
|
205
|
+
ctx.speculative += 1
|
|
206
|
+
yield
|
|
207
|
+
ensure
|
|
208
|
+
ctx.speculative -= 1
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Validate an array against items/prefixItems/minItems/maxItems.
|
|
212
|
+
# 2020-12 puts positional schemas in `prefixItems` and the rest under
|
|
213
|
+
# `items`; draft-07 and 2019-09 put positional schemas in an `items`
|
|
214
|
+
# array and send what follows the tuple to `additionalItems`. Both forms
|
|
215
|
+
# are honoured.
|
|
216
|
+
# @param data [Array] the array
|
|
217
|
+
# @param schema [Hash] string-keyed schema
|
|
218
|
+
# @param path [String] location for error messages
|
|
219
|
+
# @param evaluated [Evaluated, nil] where the items the keywords
|
|
220
|
+
# evaluate are recorded
|
|
221
|
+
# @return [Array<String>] validation errors
|
|
222
|
+
def validate_array(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil)
|
|
223
|
+
errors = []
|
|
224
|
+
min_items = schema['minItems']
|
|
225
|
+
max_items = schema['maxItems']
|
|
226
|
+
if min_items.is_a?(Numeric) && data.length < min_items
|
|
227
|
+
errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
|
|
228
|
+
end
|
|
229
|
+
if max_items.is_a?(Numeric) && data.length > max_items
|
|
230
|
+
errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
|
|
231
|
+
end
|
|
232
|
+
errors.concat(validate_unique_items(data, schema, path, ctx))
|
|
233
|
+
errors.concat(validate_items(data, schema, path, ctx, dialect, evaluated))
|
|
234
|
+
errors.concat(validate_contains(data, schema, path, ctx, dialect, evaluated))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# `unevaluatedItems` (JSON Schema 2020-12 Core Section 11.2): the
|
|
238
|
+
# keyword's schema applies to every item nothing applied to the value
|
|
239
|
+
# evaluated — not the tuple keywords, not `contains`, not any
|
|
240
|
+
# applicator that passed.
|
|
241
|
+
# @param app [Evaluation::Application] the finished application
|
|
242
|
+
# @param sub [Object] the keyword's schema
|
|
243
|
+
# @return [Array<String>] validation errors
|
|
244
|
+
def unevaluated_item_errors(app, sub)
|
|
245
|
+
data = app.data
|
|
246
|
+
ctx = app.ctx
|
|
247
|
+
path = app.path
|
|
248
|
+
errors = []
|
|
249
|
+
data.each_index do |idx|
|
|
250
|
+
check_deadline(ctx)
|
|
251
|
+
next if app.evaluated.item?(idx)
|
|
252
|
+
|
|
253
|
+
errors.concat(unevaluated_errors(data[idx], sub, "#{path}/#{idx}", ctx,
|
|
254
|
+
"#{path}: item #{idx} is not allowed (unevaluatedItems is false)"))
|
|
255
|
+
end
|
|
256
|
+
errors
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The item schemas: the positional ones first, then the schema that
|
|
260
|
+
# covers what follows them. The tuple evaluates the leading items it
|
|
261
|
+
# covers and a schema for the rest evaluates every item (their
|
|
262
|
+
# annotations).
|
|
263
|
+
# @return [Array<String>] validation errors
|
|
264
|
+
def validate_items(data, schema, path, ctx, dialect, evaluated = nil)
|
|
265
|
+
items = schema['items']
|
|
266
|
+
# 2020-12 puts positional schemas in prefixItems (items must be a
|
|
267
|
+
# schema); draft-07 and 2019-09 put them in an items array and know no
|
|
268
|
+
# prefixItems.
|
|
269
|
+
positional = if dialect == DEFAULT_DIALECT
|
|
270
|
+
schema['prefixItems'].is_a?(Array) ? schema['prefixItems'] : []
|
|
271
|
+
else
|
|
272
|
+
items.is_a?(Array) ? items : []
|
|
273
|
+
end
|
|
274
|
+
# Past a draft-07 / 2019-09 tuple it is `additionalItems` that applies.
|
|
275
|
+
rest = if items.is_a?(Array)
|
|
276
|
+
schema['additionalItems'] if keyword_known?('additionalItems', dialect)
|
|
277
|
+
else
|
|
278
|
+
items
|
|
279
|
+
end
|
|
280
|
+
return [] if positional.empty? && !schema_value?(rest)
|
|
281
|
+
|
|
282
|
+
note_evaluated_items(evaluated, positional, rest, data)
|
|
283
|
+
errors = []
|
|
284
|
+
data.each_with_index do |item, idx|
|
|
285
|
+
# As in {.validate_other_properties}: the array's length is the
|
|
286
|
+
# peer's, and an item the tuple tail rejects visits no node.
|
|
287
|
+
check_deadline(ctx)
|
|
288
|
+
item_schema = idx < positional.length ? positional[idx] : rest
|
|
289
|
+
next unless schema_value?(item_schema)
|
|
290
|
+
|
|
291
|
+
if item_schema == false && idx >= positional.length && items.is_a?(Array)
|
|
292
|
+
count_visit(ctx)
|
|
293
|
+
errors << "#{path}: item #{idx} is not allowed (additionalItems is false)"
|
|
294
|
+
next
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# An item is a smaller instance: the hop budget starts over and the
|
|
298
|
+
# depth bound counts the step, so a recursive schema describes data
|
|
299
|
+
# of any depth up to it (see {.validate_named_properties}).
|
|
300
|
+
errors.concat(validate_child(item, item_schema, "#{path}/#{idx}", ctx))
|
|
301
|
+
end
|
|
302
|
+
errors
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The tuple evaluates the leading items it covers; a schema for the
|
|
306
|
+
# rest evaluates every item (their annotations).
|
|
307
|
+
# @return [void]
|
|
308
|
+
def note_evaluated_items(evaluated, positional, rest, data)
|
|
309
|
+
return unless evaluated
|
|
310
|
+
|
|
311
|
+
evaluated.prefix!([positional.length, data.length].min)
|
|
312
|
+
evaluated.all! if schema_value?(rest)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# uniqueItems (JSON Schema 2020-12 Validation Section 6.4.3). Equality is
|
|
316
|
+
# JSON's, not Ruby's: 1 and 1.0 are the same number and two objects with
|
|
317
|
+
# the same members are equal whatever order they were written in, so the
|
|
318
|
+
# items are compared by a canonical form rather than by identity or by
|
|
319
|
+
# `eql?`.
|
|
320
|
+
# @return [Array<String>] validation errors
|
|
321
|
+
# @raise [Aborted] when a bound is hit
|
|
322
|
+
def validate_unique_items(data, schema, path, ctx)
|
|
323
|
+
return [] unless schema['uniqueItems'] == true
|
|
324
|
+
|
|
325
|
+
seen = {}
|
|
326
|
+
data.each_with_index do |item, idx|
|
|
327
|
+
count_visit(ctx)
|
|
328
|
+
key = comparable_value(item, 0, ctx)
|
|
329
|
+
first = seen[key]
|
|
330
|
+
unless first.nil?
|
|
331
|
+
return ["#{path}: items #{first} and #{idx} are equal, but uniqueItems requires every item to differ"]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
seen[key] = idx
|
|
335
|
+
end
|
|
336
|
+
[]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# A value in the form JSON equality compares: numbers as exact rationals
|
|
340
|
+
# (so 1 and 1.0 agree), objects as their members sorted by name and with
|
|
341
|
+
# either Ruby key form read as the same name.
|
|
342
|
+
#
|
|
343
|
+
# The form carries the JSON type, because JSON equality begins with it:
|
|
344
|
+
# an object is never equal to an array, however their members line up
|
|
345
|
+
# (JSON Schema 2020-12 Core Section 4.2.2). Encoding both as a bare
|
|
346
|
+
# Ruby Array made `[{}, []]` and `[{"a": 1}, [["a", 1]]]` read as
|
|
347
|
+
# duplicates, so :strict rejected a conforming result — and, through
|
|
348
|
+
# `not`, accepted one the schema rejects.
|
|
349
|
+
#
|
|
350
|
+
# Canonicalizing a value walks all of it, and the value came from the
|
|
351
|
+
# peer, so every node is accounted for like any other the walk visits.
|
|
352
|
+
# @param value [Object] the instance value
|
|
353
|
+
# @param depth [Integer] how far into the value this is
|
|
354
|
+
# @param ctx [Context] the validation context
|
|
355
|
+
# @return [Object] a value that hashes and compares as JSON equality does
|
|
356
|
+
# @raise [Aborted] when the value nests beyond the bound, or a budget is hit
|
|
357
|
+
def comparable_value(value, depth, ctx)
|
|
358
|
+
raise Aborted, "instance nested deeper than #{MAX_NODE_DEPTH}" if depth > MAX_NODE_DEPTH
|
|
359
|
+
|
|
360
|
+
count_visit(ctx)
|
|
361
|
+
case value
|
|
362
|
+
when Hash then [:object, value.map { |k, v| [k.to_s, comparable_value(v, depth + 1, ctx)] }.sort_by(&:first)]
|
|
363
|
+
when Array then [:array, value.map { |v| comparable_value(v, depth + 1, ctx) }]
|
|
364
|
+
when Numeric then [:number, exact_number(value)]
|
|
365
|
+
when String then [:string, value]
|
|
366
|
+
else value
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# @return [Object] the number as an exact rational, or as written when
|
|
371
|
+
# no rational describes it (an infinity a Ruby caller passed in)
|
|
372
|
+
def exact_number(value)
|
|
373
|
+
value.to_r
|
|
374
|
+
rescue RangeError, NoMethodError
|
|
375
|
+
value
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# `contains` (JSON Schema 2020-12 Validation Sections 6.4.4-6.4.5): the
|
|
379
|
+
# items are matched one by one and counted, and the count must lie
|
|
380
|
+
# between `minContains` (1 by default) and `maxContains`. An item the
|
|
381
|
+
# validator could only partly evaluate is neither a match nor a
|
|
382
|
+
# non-match: it widens the range the count may lie in, and where the
|
|
383
|
+
# bounds do not settle the keyword either way the node's verdict is
|
|
384
|
+
# partial, exactly as an unevaluated assertion makes it.
|
|
385
|
+
# @param data [Array] the instance
|
|
386
|
+
# @param schema [Hash] string-keyed schema
|
|
387
|
+
# @param path [String] location for error messages
|
|
388
|
+
# @param dialect [String, nil] the dialect in force
|
|
389
|
+
# @param evaluated [Evaluated, nil] where the items `contains` matched
|
|
390
|
+
# are recorded (its annotation, 2020-12 Core Section 10.3.1.3)
|
|
391
|
+
# @return [Array<String>] validation errors
|
|
392
|
+
def validate_contains(data, schema, path, ctx, dialect, evaluated = nil)
|
|
393
|
+
return [] unless keyword_known?('contains', dialect) && schema_value?(schema['contains'])
|
|
394
|
+
|
|
395
|
+
min = contains_min(schema, dialect)
|
|
396
|
+
max = contains_max(schema, dialect)
|
|
397
|
+
# Bounds that cannot overlap admit no count at all, so the keyword
|
|
398
|
+
# fails before an item is ever matched against the schema.
|
|
399
|
+
if max && min > max
|
|
400
|
+
return ["#{path}: contains requires between #{min} and #{max} matching items, which no count satisfies"]
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# 2020-12 Core Section 10.3.1.3 gives `contains` the item annotation
|
|
404
|
+
# `unevaluatedItems` reads; 2019-09 does not (its Section 9.3.1.3
|
|
405
|
+
# gives `unevaluatedItems` the annotations of `items` and
|
|
406
|
+
# `additionalItems` alone), so there a matched item stays unevaluated
|
|
407
|
+
# and the keyword still has to answer for it.
|
|
408
|
+
annotating = evaluated if contains_annotates?(dialect)
|
|
409
|
+
hits, unsure = count_contains_matches(data, schema['contains'], path, ctx, annotating)
|
|
410
|
+
errors = []
|
|
411
|
+
errors << "#{path}: expected at least #{min} items matching contains, got #{hits}" if hits + unsure < min
|
|
412
|
+
errors << "#{path}: expected at most #{max} items matching contains, got #{hits}" if max && hits > max
|
|
413
|
+
ctx.undecided += 1 if errors.empty? && unsure.positive? && (hits < min || (max && hits + unsure > max))
|
|
414
|
+
errors
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Whether the dialect gives `contains` the item annotation that
|
|
418
|
+
# `unevaluatedItems` consumes: 2020-12 does, 2019-09 does not, and
|
|
419
|
+
# draft-07 has neither keyword.
|
|
420
|
+
# @param dialect [String, nil] the dialect in force
|
|
421
|
+
# @return [Boolean]
|
|
422
|
+
def contains_annotates?(dialect)
|
|
423
|
+
dialect != DRAFT_2019_09 && dialect != DRAFT_07
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Match a `contains` schema against every item. The matches are a
|
|
427
|
+
# verdict, not output, so they are evaluated speculatively and what they
|
|
428
|
+
# could not evaluate is not left behind as this node's uncertainty.
|
|
429
|
+
# @return [Array(Integer, Integer)] the items that matched, and those the
|
|
430
|
+
# validator could not decide
|
|
431
|
+
def count_contains_matches(data, sub, path, ctx, evaluated = nil)
|
|
432
|
+
hits = 0
|
|
433
|
+
unsure = 0
|
|
434
|
+
data.each_with_index do |item, idx|
|
|
435
|
+
before = ctx.undecided
|
|
436
|
+
errors = speculatively(ctx) { validate_child(item, sub, "#{path}/#{idx}", ctx) }
|
|
437
|
+
if errors.empty? && ctx.undecided > before
|
|
438
|
+
unsure += 1
|
|
439
|
+
elsif errors.empty?
|
|
440
|
+
hits += 1
|
|
441
|
+
evaluated&.index!(idx)
|
|
442
|
+
end
|
|
443
|
+
ctx.undecided = before
|
|
444
|
+
end
|
|
445
|
+
[hits, unsure]
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# The bounded scan for keywords the validator does not evaluate, so the
|
|
6
|
+
# client can say when its validation is only partial. Extended into
|
|
7
|
+
# SchemaValidator, so the methods are its own.
|
|
8
|
+
module KeywordScan
|
|
9
|
+
# List the unsupported JSON Schema keywords a schema uses (anywhere: at the
|
|
10
|
+
# top level or nested in subschemas). Property names that merely look like
|
|
11
|
+
# keywords (e.g. a property called 'not') are not reported, and
|
|
12
|
+
# data-carrying keywords (enum/const/default/examples) are not scanned.
|
|
13
|
+
# The scan stops at the same bounds as {.check_schema} (a schema beyond
|
|
14
|
+
# them is unusable anyway), so a huge server-supplied schema is never
|
|
15
|
+
# walked whole.
|
|
16
|
+
# @param schema [Object] the JSON schema (string or symbol keys)
|
|
17
|
+
# @return [Array<String>] unique unsupported keywords, in discovery order
|
|
18
|
+
def unsupported_keywords(schema)
|
|
19
|
+
found = []
|
|
20
|
+
root = normalize_schema(schema)
|
|
21
|
+
declared = dialect(root)
|
|
22
|
+
canonical = declared && canonical_dialect(declared)
|
|
23
|
+
scan = { count: 0, dialect: canonical, walked: {}.compare_by_identity, anchors: nil,
|
|
24
|
+
pending: [[root, 0, canonical]] }
|
|
25
|
+
scan_positions(root, found, scan)
|
|
26
|
+
found.uniq
|
|
27
|
+
rescue TooLarge
|
|
28
|
+
# Unusable anyway: check_schema reports it.
|
|
29
|
+
found.uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Read every queued position. Like the preflight walk, the scan runs on
|
|
33
|
+
# a stack of its own rather than on the interpreter's: a shallow
|
|
34
|
+
# document whose `$ref`s chain through hundreds of schemas is walked in
|
|
35
|
+
# constant stack space, so scanning one cannot overflow the thread a
|
|
36
|
+
# transport reads on.
|
|
37
|
+
# @return [void]
|
|
38
|
+
def scan_positions(root, found, scan)
|
|
39
|
+
until scan[:pending].empty?
|
|
40
|
+
schema, depth, dialect = scan[:pending].pop
|
|
41
|
+
scan_position(schema, root, found, depth, scan, dialect)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Collect the unsupported keywords one schema position uses (keywords
|
|
46
|
+
# the dialect does not define are unknown, not unsupported) and queue
|
|
47
|
+
# what it leads to. What a local `$ref` applies is scanned too, wherever
|
|
48
|
+
# it lives (a definition bag the dialect does not walk included); each
|
|
49
|
+
# object is scanned once.
|
|
50
|
+
# @param schema [Object] a (sub)schema; non-Hash values are ignored
|
|
51
|
+
# @param root [Hash] the normalized root schema
|
|
52
|
+
# @param found [Array<String>] accumulator
|
|
53
|
+
# @param scan [Hash] :count of subschemas seen so far, the canonical
|
|
54
|
+
# :dialect, the :walked objects, the memoized :anchors index and the
|
|
55
|
+
# :pending positions
|
|
56
|
+
# @return [void]
|
|
57
|
+
def scan_position(schema, root, found, depth, scan, dialect = scan[:dialect])
|
|
58
|
+
return unless schema.is_a?(Hash) && depth <= MAX_SCHEMA_DEPTH
|
|
59
|
+
return if scan[:walked].key?(schema)
|
|
60
|
+
|
|
61
|
+
scan[:walked][schema] = true
|
|
62
|
+
scan[:count] += 1
|
|
63
|
+
return if scan[:count] > MAX_SUBSCHEMAS
|
|
64
|
+
|
|
65
|
+
# An embedded resource declaring its own $schema is scanned under it.
|
|
66
|
+
dialect = embedded_dialect(schema, dialect) || dialect
|
|
67
|
+
if dialect == DRAFT_07 && schema.key?('$ref')
|
|
68
|
+
# Nothing beside the $ref is applied; the definitions stay reachable.
|
|
69
|
+
referenced = [referenced_position(schema, root, depth, scan, dialect)].compact
|
|
70
|
+
return queue_scan(scan, schema, depth, dialect, referenced, :each_definition)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A dynamic reference is applied like a `$ref` — where it binds is
|
|
74
|
+
# decided during evaluation — so it is scanned like one.
|
|
75
|
+
dynamic = DYNAMIC_REFERENCE_KEYWORDS.select { |k| schema.key?(k) && keyword_known?(k, dialect) }
|
|
76
|
+
found.concat((schema.keys & UNSUPPORTED_KEYWORDS).select { |k| keyword_known?(k, dialect) })
|
|
77
|
+
referenced = (['$ref'] + dynamic).filter_map { |k| referenced_position(schema, root, depth, scan, dialect, k) }
|
|
78
|
+
queue_scan(scan, schema, depth, dialect, referenced, :each_subschema)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Queue the positions under a schema object (in document order, the
|
|
82
|
+
# stack being read from its end), then what its references reach.
|
|
83
|
+
# @param walker [Symbol] :each_subschema or :each_definition
|
|
84
|
+
# @return [void]
|
|
85
|
+
def queue_scan(scan, schema, depth, dialect, referenced, walker)
|
|
86
|
+
positions = []
|
|
87
|
+
send(walker, schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
|
|
88
|
+
scan[:pending].concat(positions.reverse)
|
|
89
|
+
scan[:pending].concat(referenced.reverse)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The position what a local reference applies is scanned at
|
|
93
|
+
# (unresolvable or external references are the preflight's business).
|
|
94
|
+
# @param keyword [String] `$ref`, or a dynamic reference applied as one
|
|
95
|
+
# @return [Array, nil]
|
|
96
|
+
def referenced_position(schema, root, depth, scan, dialect, keyword = '$ref')
|
|
97
|
+
ref = schema[keyword]
|
|
98
|
+
return nil unless ref.is_a?(String)
|
|
99
|
+
return nil if external_ref?(ref, root, scan[:dialect], scan, from: schema)
|
|
100
|
+
|
|
101
|
+
target = resolve_reference(root, ref, scan[:dialect], scan, from: schema)
|
|
102
|
+
return nil if target.equal?(UNRESOLVED)
|
|
103
|
+
|
|
104
|
+
# What a reference reaches is scanned under its own resource's dialect
|
|
105
|
+
# and at its own lexical depth, so member order and reference fan-out
|
|
106
|
+
# cannot hide its keywords. A target that is not a schema object has
|
|
107
|
+
# no lexical depth at all — nil, never `false`, so the depth the
|
|
108
|
+
# opaque-pointer branch compares stays a number.
|
|
109
|
+
scan[:depths] ||= lexical_depths(root, scan[:dialect])
|
|
110
|
+
lexical = scan[:depths][target] if target.is_a?(Hash)
|
|
111
|
+
position, opaque = pointer_position(ref, root, scan[:dialect], scan, schema)
|
|
112
|
+
target_depth = if opaque
|
|
113
|
+
[position, lexical].compact.max || (depth + 1)
|
|
114
|
+
else
|
|
115
|
+
lexical || position || (depth + 1)
|
|
116
|
+
end
|
|
117
|
+
[target, target_depth, (target.is_a?(Hash) && indexed_dialect(target, scan)) || dialect]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# The bounded, string-keyed copy of a peer-supplied schema document.
|
|
6
|
+
# Extended into SchemaValidator, so the methods are its own.
|
|
7
|
+
module Normalization
|
|
8
|
+
# A copy of the schema with every structural Hash key as a String, so
|
|
9
|
+
# keyword lookups and JSON pointers see one key form. The value of a
|
|
10
|
+
# data-carrying keyword (enum, const, default, examples) of a schema
|
|
11
|
+
# object is left exactly as given, the walk stops at the nesting bound,
|
|
12
|
+
# and — given a budget — the copy stops as soon as the document holds
|
|
13
|
+
# more structural elements (objects, their entries and array members,
|
|
14
|
+
# boolean subschemas included) than MAX_STRUCTURAL_OBJECTS, before the
|
|
15
|
+
# rest is ever read, or as soon as the validation deadline has passed.
|
|
16
|
+
# @param node [Object]
|
|
17
|
+
# @param depth [Integer]
|
|
18
|
+
# @param budget [Hash, nil] :objects copied so far, :deadline (optional)
|
|
19
|
+
# @param mode [Symbol] how this position is read ({#member_mode})
|
|
20
|
+
# @return [Object]
|
|
21
|
+
# @raise [TooLarge] when the budget is exceeded, or the document is
|
|
22
|
+
# nested beyond what MAX_SCHEMA_DEPTH schema levels can hold (a
|
|
23
|
+
# subtree is never kept unread)
|
|
24
|
+
# @raise [Aborted] when the deadline passed
|
|
25
|
+
def deep_stringify(node, depth = 0, budget = nil, mode = :schema)
|
|
26
|
+
# Each schema level takes at most two document levels (a keyword map
|
|
27
|
+
# or array, then the subschema), so a deeper document holds more
|
|
28
|
+
# schema levels than the preflight walk admits.
|
|
29
|
+
raise TooLarge, "schema nesting depth exceeds #{MAX_SCHEMA_DEPTH}" if depth > (MAX_SCHEMA_DEPTH * 2) + 2
|
|
30
|
+
|
|
31
|
+
case node
|
|
32
|
+
when Hash
|
|
33
|
+
charge_structure(budget, node.size + 1)
|
|
34
|
+
node.to_h do |key, value|
|
|
35
|
+
name = key.to_s
|
|
36
|
+
[name, stringify_member(name, value, depth, budget, mode)]
|
|
37
|
+
end
|
|
38
|
+
when Array
|
|
39
|
+
charge_structure(budget, node.length + 1)
|
|
40
|
+
node.map.with_index do |value, idx|
|
|
41
|
+
deep_stringify(value, depth + 1, budget, member_mode(idx.to_s, value, mode))
|
|
42
|
+
end
|
|
43
|
+
else node
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The copy of one member: what a schema object holds under a data
|
|
48
|
+
# keyword is kept as given (its key form is the instance's, and it is
|
|
49
|
+
# compared for equality as written), everything else is copied in the
|
|
50
|
+
# mode its position implies.
|
|
51
|
+
# @return [Object]
|
|
52
|
+
def stringify_member(name, value, depth, budget, mode)
|
|
53
|
+
member = member_mode(name, value, mode)
|
|
54
|
+
return value if member == :data
|
|
55
|
+
|
|
56
|
+
deep_stringify(value, depth + 1, budget, member)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# How the member a name selects is read: :data (a data keyword of a
|
|
60
|
+
# schema object, kept as given), :list (a map or array of subschemas),
|
|
61
|
+
# :schema, or :plain (anything else — copied, never read as a schema).
|
|
62
|
+
# Only a schema object has keywords: a property, definition or pattern
|
|
63
|
+
# named `enum`, `const`, `default` or `examples` is a schema position
|
|
64
|
+
# like any other (JSON Schema 2020-12 Core Section 4.3.1), and so is
|
|
65
|
+
# everything under a keyword no dialect defines.
|
|
66
|
+
# @return [Symbol]
|
|
67
|
+
def member_mode(name, value, mode)
|
|
68
|
+
return :data if mode == :data
|
|
69
|
+
return :schema if mode == :list
|
|
70
|
+
return :plain unless mode == :schema
|
|
71
|
+
return :data if DATA_KEYWORDS.include?(name)
|
|
72
|
+
return :list if list_member?(name, value)
|
|
73
|
+
|
|
74
|
+
SUBSCHEMA_KEYWORDS.include?(name) ? :schema : :plain
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] whether a keyword holds a map or array of
|
|
78
|
+
# subschemas in the form it was written (draft-07 / 2019-09 read an
|
|
79
|
+
# `items` array positionally)
|
|
80
|
+
def list_member?(name, value)
|
|
81
|
+
(SUBSCHEMA_MAP_KEYWORDS.include?(name) && value.is_a?(Hash)) ||
|
|
82
|
+
((SUBSCHEMA_ARRAY_KEYWORDS.include?(name) || name == 'items') && value.is_a?(Array))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Account for structural elements about to be copied.
|
|
86
|
+
# @raise [TooLarge] when the budget is exceeded
|
|
87
|
+
# @raise [Aborted] when the deadline passed
|
|
88
|
+
def charge_structure(budget, count)
|
|
89
|
+
return unless budget
|
|
90
|
+
|
|
91
|
+
budget[:objects] += count
|
|
92
|
+
if budget[:objects] > MAX_STRUCTURAL_OBJECTS
|
|
93
|
+
raise TooLarge,
|
|
94
|
+
"schema has more than #{MAX_STRUCTURAL_OBJECTS} structural elements"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
deadline = budget[:deadline]
|
|
98
|
+
return unless deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
99
|
+
|
|
100
|
+
raise Aborted, 'time budget exhausted while reading the schema'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|