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,517 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# Applying schemas to one instance value, without recursing per schema.
|
|
6
|
+
# Extended into SchemaValidator, so the methods are its own.
|
|
7
|
+
#
|
|
8
|
+
# A validation walks two things at once: down the instance, and — at each
|
|
9
|
+
# value — through the schemas that apply to it. Only the first nests the
|
|
10
|
+
# data, and only the first is counted by MAX_NODE_DEPTH; the second is a
|
|
11
|
+
# `$ref` hop or a composition branch applied to the *same* value, bounded
|
|
12
|
+
# by MAX_REF_DEPTH and the node-visit cap instead.
|
|
13
|
+
#
|
|
14
|
+
# A schema composed through a handful of `$defs` mixins applies several
|
|
15
|
+
# of those per instance level, so if each of them cost a Ruby frame the
|
|
16
|
+
# stack would grow with the product of the instance depth and the mixin
|
|
17
|
+
# count — and a conforming result would abort on a transport reader
|
|
18
|
+
# thread's stack, which is where a tool call is validated. So the
|
|
19
|
+
# same-instance applications run on an explicit stack here (this
|
|
20
|
+
# module's `pending` list) rather than on the interpreter's: a step is
|
|
21
|
+
# requested by returning `[:apply, schema, ref_depth, speculative,
|
|
22
|
+
# continuation, collect]` and finished by returning `[:done, errors,
|
|
23
|
+
# evaluated]`, and {SchemaValidator.validate_node} drives the two until
|
|
24
|
+
# the outermost application is done. A Ruby frame is then spent only on
|
|
25
|
+
# a step into a child value ({SchemaValidator.validate_child}), which is
|
|
26
|
+
# exactly what MAX_NODE_DEPTH bounds.
|
|
27
|
+
#
|
|
28
|
+
# Every application of a schema to an object or an array keeps what it
|
|
29
|
+
# evaluated of the value (an {Evaluated}): the annotations
|
|
30
|
+
# `unevaluatedProperties` and `unevaluatedItems` read, collected from the
|
|
31
|
+
# node's own keywords and from every in-place applicator whose subschema
|
|
32
|
+
# passed. A subschema hands its own record back with its verdict, and a
|
|
33
|
+
# failed one hands back nothing.
|
|
34
|
+
module Evaluation
|
|
35
|
+
# One (sub)schema being applied to one value: the instance and the
|
|
36
|
+
# context are fixed for a whole trampoline (the same value throughout),
|
|
37
|
+
# the rest is what this application accumulates.
|
|
38
|
+
# @!attribute errors
|
|
39
|
+
# @return [Array<String>] this node's own errors so far
|
|
40
|
+
# @!attribute counted_before
|
|
41
|
+
# @return [Integer] ctx.errors when the application began, so only
|
|
42
|
+
# this node's own errors are charged to MAX_ERRORS
|
|
43
|
+
# @!attribute evaluated
|
|
44
|
+
# @return [Evaluated, nil] what was evaluated of an object or array
|
|
45
|
+
# @!attribute collecting
|
|
46
|
+
# @return [Boolean] whether an unevaluated keyword at this node or at
|
|
47
|
+
# one applying it to the same value reads the annotations, so every
|
|
48
|
+
# branch that passes must be evaluated rather than the first one
|
|
49
|
+
Application = Struct.new(:data, :path, :ctx, :schema, :dialect, :ref_depth, :errors, :counted_before,
|
|
50
|
+
:evaluated, :collecting, keyword_init: true)
|
|
51
|
+
|
|
52
|
+
# The composition keywords, in the order they are applied. Each step
|
|
53
|
+
# takes the application and a continuation, and returns a step.
|
|
54
|
+
COMPOSITION_STEPS = %i[compose_all_of compose_any_of compose_one_of compose_not compose_conditional
|
|
55
|
+
compose_dependent_schemas].freeze
|
|
56
|
+
|
|
57
|
+
# The keywords that apply another schema to the same instance before
|
|
58
|
+
# the node's own keywords: `$ref` and the dynamic references (2020-12
|
|
59
|
+
# Core Section 8.2.3.2, 2019-09 Core Section 8.2.4.2.1). Applied in
|
|
60
|
+
# this order, each alongside the others (draft-07's `$ref` alone
|
|
61
|
+
# replaces its siblings).
|
|
62
|
+
REFERENCE_KEYWORDS = %w[$ref $dynamicRef $recursiveRef].freeze
|
|
63
|
+
|
|
64
|
+
# Begin applying one (sub)schema to the value, under the bound on the
|
|
65
|
+
# walk itself: one more node visited.
|
|
66
|
+
# @param data [Object] the value
|
|
67
|
+
# @param schema [Object] a subschema (Hash or boolean)
|
|
68
|
+
# @param path [String] location for error messages
|
|
69
|
+
# @param ctx [Context] the validation context
|
|
70
|
+
# @param ref_depth [Integer] $ref hops taken to reach this schema
|
|
71
|
+
# @param collecting [Boolean] whether the annotations this application
|
|
72
|
+
# produces are read by an unevaluated keyword above it
|
|
73
|
+
# @return [Array] a step: [:done, errors, evaluated] or [:apply, ...]
|
|
74
|
+
# @raise [Aborted] when a bound is hit
|
|
75
|
+
def start_node(data, schema, path, ctx, ref_depth, collecting: false)
|
|
76
|
+
count_visit(ctx)
|
|
77
|
+
return [:done, [], nil] if schema == true
|
|
78
|
+
return [:done, count_errors(ctx, ["#{path}: schema false accepts no value"]), nil] if schema == false
|
|
79
|
+
return [:done, [], nil] unless schema.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
dialect = node_dialect(schema, ctx)
|
|
82
|
+
app = Application.new(data: data, path: path, ctx: ctx, schema: schema, dialect: dialect,
|
|
83
|
+
ref_depth: ref_depth, errors: [], counted_before: ctx.errors,
|
|
84
|
+
evaluated: (Evaluated.new if data.is_a?(Hash) || data.is_a?(Array)),
|
|
85
|
+
collecting: collecting || reads_annotations?(schema, dialect))
|
|
86
|
+
apply_references(app, applied_references(app))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param schema [Hash]
|
|
90
|
+
# @param dialect [String, nil]
|
|
91
|
+
# @return [Boolean] whether the node carries an unevaluated keyword
|
|
92
|
+
# that reads the annotations of the schemas applied to its value
|
|
93
|
+
def reads_annotations?(schema, dialect)
|
|
94
|
+
%w[unevaluatedProperties unevaluatedItems].any? do |keyword|
|
|
95
|
+
keyword_known?(keyword, dialect) && schema_value?(schema[keyword])
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The reference keywords a node applies: `$ref` and the dynamic
|
|
100
|
+
# references the dialect defines. Where a dynamic reference binds is
|
|
101
|
+
# {References#dynamic_binding}'s business, and {#ref_target} asks it
|
|
102
|
+
# once the plain target has resolved.
|
|
103
|
+
# @param app [Application]
|
|
104
|
+
# @return [Array<String>]
|
|
105
|
+
def applied_references(app)
|
|
106
|
+
REFERENCE_KEYWORDS.select { |keyword| app.schema.key?(keyword) && keyword_known?(keyword, app.dialect) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Apply the references a schema object carries, one after the other,
|
|
110
|
+
# then its own keywords. draft-07: "$ref" replaces the schema it
|
|
111
|
+
# appears in; later drafts apply it alongside the sibling keywords.
|
|
112
|
+
# @param app [Application]
|
|
113
|
+
# @param keywords [Array<String>] the references still to apply
|
|
114
|
+
# @return [Array] a step
|
|
115
|
+
def apply_references(app, keywords)
|
|
116
|
+
return apply_keywords(app) if keywords.empty?
|
|
117
|
+
|
|
118
|
+
keyword = keywords.first
|
|
119
|
+
replaces = keyword == '$ref' && app.dialect == DRAFT_07
|
|
120
|
+
kind, target = ref_target(app, keyword)
|
|
121
|
+
if kind == :errors
|
|
122
|
+
return [:done, target, app.evaluated] if replaces
|
|
123
|
+
|
|
124
|
+
app.errors.concat(target)
|
|
125
|
+
return apply_references(app, keywords.drop(1))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
[:apply, target, app.ref_depth + 1, false, lambda do |errors, evaluated|
|
|
129
|
+
next [:done, errors, evaluated] if replaces
|
|
130
|
+
|
|
131
|
+
app.errors.concat(errors)
|
|
132
|
+
app.evaluated&.merge!(evaluated) if errors.empty?
|
|
133
|
+
apply_references(app, keywords.drop(1))
|
|
134
|
+
end, app.collecting]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Resolve a reference a schema object carries.
|
|
138
|
+
# @param app [Application]
|
|
139
|
+
# @param keyword [String] `$ref`, or a dynamic reference applied as one
|
|
140
|
+
# @return [Array(Symbol, Object)] [:target, schema] or [:errors, errors]
|
|
141
|
+
# @raise [Aborted] when the hop budget is exhausted
|
|
142
|
+
def ref_target(app, keyword = '$ref')
|
|
143
|
+
ctx = app.ctx
|
|
144
|
+
ref = app.schema[keyword]
|
|
145
|
+
if unusable_ref?(ref, ctx, app.schema)
|
|
146
|
+
return ref_problem(app, "external #{keyword} #{clip(ref.inspect)} is not dereferenced")
|
|
147
|
+
end
|
|
148
|
+
if app.ref_depth >= MAX_REF_DEPTH
|
|
149
|
+
raise Aborted, "#{keyword} chain exceeds #{MAX_REF_DEPTH} hops (cycle?) at #{clip(ref.inspect)}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
target = resolve_reference(ctx.root, ref, ctx.dialect, ctx, from: app.schema)
|
|
153
|
+
return ref_problem(app, "unresolvable local #{keyword} #{clip(ref.inspect)}") if target.equal?(UNRESOLVED)
|
|
154
|
+
|
|
155
|
+
if keyword != '$ref'
|
|
156
|
+
kind, bound = dynamic_binding(app.schema, keyword, ctx.root, ctx.dialect, ctx)
|
|
157
|
+
target = bound if kind == :bound
|
|
158
|
+
end
|
|
159
|
+
unless schema_value?(target)
|
|
160
|
+
return ref_problem(app, "#{keyword} #{clip(ref.inspect)} does not point at a schema")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
[:target, target]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param ref [Object] the reference's value
|
|
167
|
+
# @param ctx [Context] the validation context
|
|
168
|
+
# @param from [Hash] the schema object holding the reference
|
|
169
|
+
# @return [Boolean] whether it is a reference this validator never follows
|
|
170
|
+
def unusable_ref?(ref, ctx, from)
|
|
171
|
+
!ref.is_a?(String) || external_ref?(ref, ctx.root, ctx.dialect, ctx, from: from)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param app [Application]
|
|
175
|
+
# @param message [String] the problem, without the location
|
|
176
|
+
# @return [Array(Symbol, Array<String>)]
|
|
177
|
+
def ref_problem(app, message)
|
|
178
|
+
[:errors, count_errors(app.ctx, ["#{app.path}: #{message}"])]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Apply the assertions this validator evaluates for the value's own
|
|
182
|
+
# type, then hand over to the composition keywords. A step into a child
|
|
183
|
+
# value happens here, and is the one place a Ruby frame is spent.
|
|
184
|
+
# @param app [Application]
|
|
185
|
+
# @return [Array] a step
|
|
186
|
+
def apply_keywords(app)
|
|
187
|
+
data = app.data
|
|
188
|
+
schema = app.schema
|
|
189
|
+
errors = app.errors
|
|
190
|
+
errors.concat(validate_type(data, schema['type'], app.path)) if schema.key?('type')
|
|
191
|
+
# A node its own `type` already rejected is decided: the keywords for
|
|
192
|
+
# the value's actual type could only add detail, and evaluating them
|
|
193
|
+
# would spend the peer's `patternProperties` expressions and item
|
|
194
|
+
# schemas on a value this schema has refused.
|
|
195
|
+
return compose(app) unless errors.empty?
|
|
196
|
+
|
|
197
|
+
errors.concat(validate_enum(data, schema, app.path))
|
|
198
|
+
case data
|
|
199
|
+
when Hash then errors.concat(validate_object(data, schema, app.path, app.ctx, app.dialect, app.evaluated))
|
|
200
|
+
when Array then errors.concat(validate_array(data, schema, app.path, app.ctx, app.dialect, app.evaluated))
|
|
201
|
+
when String then errors.concat(validate_string(data, schema, app.path, app.ctx.deadline))
|
|
202
|
+
when Numeric then errors.concat(validate_number(data, schema, app.path, app.dialect))
|
|
203
|
+
end
|
|
204
|
+
compose(app)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Run the composition keywords in order, each handed the step that
|
|
208
|
+
# continues with the next.
|
|
209
|
+
# @param app [Application]
|
|
210
|
+
# @param index [Integer] the composition keyword to apply
|
|
211
|
+
# @return [Array] a step
|
|
212
|
+
def compose(app, index = 0)
|
|
213
|
+
return finish_node(app) if index >= COMPOSITION_STEPS.length
|
|
214
|
+
|
|
215
|
+
send(COMPOSITION_STEPS[index], app) { compose(app, index + 1) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Finish an application. The unevaluated keywords come last: they read
|
|
219
|
+
# what every other keyword and every passed applicator evaluated of the
|
|
220
|
+
# value. A keyword the validator does not evaluate makes this node's
|
|
221
|
+
# verdict partial, so a pass here is not a proof for not / oneOf / if.
|
|
222
|
+
# A node its supported assertions already rejected is decided whatever
|
|
223
|
+
# else it holds, and pays for no such measurement. Errors raised by
|
|
224
|
+
# nested nodes were counted when they were produced; only this node's
|
|
225
|
+
# own errors are new.
|
|
226
|
+
# @param app [Application]
|
|
227
|
+
# @return [Array] a step
|
|
228
|
+
def finish_node(app)
|
|
229
|
+
ctx = app.ctx
|
|
230
|
+
apply_unevaluated(app) if app.errors.empty? && app.evaluated
|
|
231
|
+
ctx.undecided += 1 if app.errors.empty? && partial_keywords?(app.schema, app.dialect, app.data, ctx)
|
|
232
|
+
[:done, count_errors(ctx, app.errors, already_counted: ctx.errors - app.counted_before), app.evaluated]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# `unevaluatedProperties` / `unevaluatedItems` (JSON Schema 2020-12
|
|
236
|
+
# Core Sections 11.3 and 11.2): the keyword's schema applies to every
|
|
237
|
+
# member or item nothing applied to this value evaluated, and what it
|
|
238
|
+
# validated counts as evaluated from then on.
|
|
239
|
+
# @param app [Application]
|
|
240
|
+
# @return [void]
|
|
241
|
+
def apply_unevaluated(app)
|
|
242
|
+
keyword = app.data.is_a?(Hash) ? 'unevaluatedProperties' : 'unevaluatedItems'
|
|
243
|
+
sub = app.schema[keyword]
|
|
244
|
+
return unless keyword_known?(keyword, app.dialect) && schema_value?(sub)
|
|
245
|
+
|
|
246
|
+
errors = app.data.is_a?(Hash) ? unevaluated_property_errors(app, sub) : unevaluated_item_errors(app, sub)
|
|
247
|
+
app.errors.concat(errors)
|
|
248
|
+
app.evaluated.all!
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The schema half of a dependency: `dependentSchemas` in 2019-09 and
|
|
252
|
+
# 2020-12, and the schema entries of draft-07's `dependencies` (JSON
|
|
253
|
+
# Schema 2020-12 Core Section 10.2.2.4, draft-07 Section 6.5.7). Each
|
|
254
|
+
# dependency whose trigger the instance carries applies its schema to
|
|
255
|
+
# that same instance, so — like allOf — it runs on the trampoline
|
|
256
|
+
# rather than on the interpreter's stack.
|
|
257
|
+
# @param app [Application]
|
|
258
|
+
# @return [Array] a step
|
|
259
|
+
def compose_dependent_schemas(app, &cont)
|
|
260
|
+
return cont.call unless app.data.is_a?(Hash)
|
|
261
|
+
|
|
262
|
+
subs = triggered_dependencies(app)
|
|
263
|
+
return cont.call if subs.empty?
|
|
264
|
+
|
|
265
|
+
dependency_branch(app, subs, 0, cont)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param app [Application]
|
|
269
|
+
# @return [Array<Array(String, Object)>] the trigger and schema of every
|
|
270
|
+
# dependency the instance turns on, in document order
|
|
271
|
+
def triggered_dependencies(app)
|
|
272
|
+
subs = []
|
|
273
|
+
%w[dependentSchemas dependencies].each do |keyword|
|
|
274
|
+
map = app.schema[keyword] if keyword_known?(keyword, app.dialect)
|
|
275
|
+
next unless map.is_a?(Hash)
|
|
276
|
+
|
|
277
|
+
map.each do |trigger, sub|
|
|
278
|
+
subs << [trigger.to_s, sub] if schema_value?(sub) && property_present?(app.data, trigger)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
subs
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# @param app [Application]
|
|
285
|
+
# @param subs [Array] the triggered dependencies
|
|
286
|
+
# @param idx [Integer] the dependency to apply
|
|
287
|
+
# @param cont [Proc] what follows
|
|
288
|
+
# @return [Array] a step
|
|
289
|
+
def dependency_branch(app, subs, idx, cont)
|
|
290
|
+
return cont.call if idx >= subs.length
|
|
291
|
+
|
|
292
|
+
trigger, sub = subs[idx]
|
|
293
|
+
[:apply, sub, app.ref_depth, false, lambda do |errors, evaluated|
|
|
294
|
+
if errors.empty?
|
|
295
|
+
app.evaluated&.merge!(evaluated)
|
|
296
|
+
else
|
|
297
|
+
app.errors << "#{app.path}: does not satisfy the schema required by property " \
|
|
298
|
+
"'#{clip(trigger)}' (#{clip(errors.first.to_s)})"
|
|
299
|
+
end
|
|
300
|
+
dependency_branch(app, subs, idx + 1, cont)
|
|
301
|
+
end, app.collecting]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# allOf: the first failing branch decides it, and later branches are
|
|
305
|
+
# not evaluated (they cannot change the outcome, but could abort it).
|
|
306
|
+
# @param app [Application]
|
|
307
|
+
# @return [Array] a step
|
|
308
|
+
def compose_all_of(app, &cont)
|
|
309
|
+
subs = app.schema['allOf']
|
|
310
|
+
return cont.call unless subs.is_a?(Array)
|
|
311
|
+
|
|
312
|
+
all_of_branch(app, subs, 0, cont)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# @param app [Application]
|
|
316
|
+
# @param subs [Array] the allOf branches
|
|
317
|
+
# @param idx [Integer] the branch to evaluate
|
|
318
|
+
# @param cont [Proc] what follows allOf
|
|
319
|
+
# @return [Array] a step
|
|
320
|
+
def all_of_branch(app, subs, idx, cont)
|
|
321
|
+
return cont.call if idx >= subs.length
|
|
322
|
+
|
|
323
|
+
[:apply, subs[idx], app.ref_depth, false, lambda do |errors, evaluated|
|
|
324
|
+
if errors.empty?
|
|
325
|
+
app.evaluated&.merge!(evaluated)
|
|
326
|
+
next all_of_branch(app, subs, idx + 1, cont)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
app.errors << "#{app.path}: does not satisfy allOf/#{idx} (#{clip(errors.first.to_s)})"
|
|
330
|
+
cont.call
|
|
331
|
+
end, app.collecting]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# anyOf is monotonic: a definite pass decides it whatever the other
|
|
335
|
+
# branches, and only undecided branches leave it undecided. Where an
|
|
336
|
+
# unevaluated keyword reads the annotations, every branch is evaluated
|
|
337
|
+
# (each one that passes contributes what it evaluated); otherwise the
|
|
338
|
+
# first definite pass ends it, so a branch that cannot change the
|
|
339
|
+
# outcome cannot abort a decided validation.
|
|
340
|
+
# @param app [Application]
|
|
341
|
+
# @return [Array] a step
|
|
342
|
+
def compose_any_of(app, &cont)
|
|
343
|
+
subs = app.schema['anyOf']
|
|
344
|
+
return cont.call unless subs.is_a?(Array)
|
|
345
|
+
|
|
346
|
+
branch_verdicts(app, subs, stop: ->(vs) { !app.collecting && vs.include?(:pass) },
|
|
347
|
+
decided: ->(vs) { vs.all?(:fail) }) do |verdicts, evaluations|
|
|
348
|
+
if verdicts.all?(:fail)
|
|
349
|
+
app.errors << "#{app.path}: does not satisfy any schema in anyOf"
|
|
350
|
+
else
|
|
351
|
+
merge_passed(app, verdicts, evaluations)
|
|
352
|
+
end
|
|
353
|
+
cont.call
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# oneOf: two definite passes decide it; with an undecided branch and at
|
|
358
|
+
# most one pass, "exactly one" cannot be told either way.
|
|
359
|
+
# @param app [Application]
|
|
360
|
+
# @return [Array] a step
|
|
361
|
+
def compose_one_of(app, &cont)
|
|
362
|
+
subs = app.schema['oneOf']
|
|
363
|
+
return cont.call unless subs.is_a?(Array)
|
|
364
|
+
|
|
365
|
+
branch_verdicts(app, subs, stop: ->(vs) { vs.count(:pass) > 1 },
|
|
366
|
+
decided: ->(vs) { vs.none?(:undecided) }) do |verdicts, evaluations|
|
|
367
|
+
matches = verdicts.count(:pass)
|
|
368
|
+
if matches > 1 || (verdicts.none?(:undecided) && matches != 1)
|
|
369
|
+
app.errors << "#{app.path}: satisfies #{matches} schemas in oneOf, expected exactly one"
|
|
370
|
+
elsif matches == 1
|
|
371
|
+
merge_passed(app, verdicts, evaluations)
|
|
372
|
+
end
|
|
373
|
+
cont.call
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Take over what every passed branch evaluated of the value.
|
|
378
|
+
# @return [void]
|
|
379
|
+
def merge_passed(app, verdicts, evaluations)
|
|
380
|
+
return unless app.evaluated
|
|
381
|
+
|
|
382
|
+
verdicts.each_with_index { |verdict, idx| app.evaluated.merge!(evaluations[idx]) if verdict == :pass }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# @param app [Application]
|
|
386
|
+
# @return [Array] a step
|
|
387
|
+
def compose_not(app, &cont)
|
|
388
|
+
return cont.call unless app.schema.key?('not')
|
|
389
|
+
|
|
390
|
+
branch_verdict(app, app.schema['not']) do |verdict, _evaluated|
|
|
391
|
+
app.errors << "#{app.path}: value satisfies the schema in not" if verdict == :pass
|
|
392
|
+
cont.call
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# if / then / else. An `if` asserts nothing by itself, but it is
|
|
397
|
+
# evaluated even without `then` or `else`: the annotations of a
|
|
398
|
+
# condition that passed are what an `unevaluated*` beside it reads
|
|
399
|
+
# (JSON Schema 2020-12 Section 10.2.2.1). An undecided condition
|
|
400
|
+
# applies neither branch, but may still be settled by the branches
|
|
401
|
+
# agreeing ({#unconditional_conditional}). A condition that passed
|
|
402
|
+
# evaluated the value, and so does the branch applied.
|
|
403
|
+
# @param app [Application]
|
|
404
|
+
# @return [Array] a step
|
|
405
|
+
def compose_conditional(app, &cont)
|
|
406
|
+
schema = app.schema
|
|
407
|
+
return cont.call unless schema.key?('if')
|
|
408
|
+
|
|
409
|
+
branch_verdict(app, schema['if']) do |verdict, evaluated|
|
|
410
|
+
branch = { pass: 'then', fail: 'else' }[verdict]
|
|
411
|
+
next unconditional_conditional(app, cont) if branch.nil?
|
|
412
|
+
|
|
413
|
+
app.evaluated&.merge!(evaluated) if verdict == :pass
|
|
414
|
+
next cont.call unless schema.key?(branch)
|
|
415
|
+
|
|
416
|
+
[:apply, schema[branch], app.ref_depth, false, lambda do |errors, branch_evaluated|
|
|
417
|
+
app.errors.concat(errors)
|
|
418
|
+
app.evaluated&.merge!(branch_evaluated) if errors.empty?
|
|
419
|
+
cont.call
|
|
420
|
+
end, app.collecting]
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# A condition this validator cannot decide still settles the instance
|
|
425
|
+
# when both outcomes reject it: exactly one of `then` and `else` is
|
|
426
|
+
# applied (JSON Schema 2020-12 Section 10.2.2.2 and 10.2.2.3), so a
|
|
427
|
+
# value both of them reject is invalid whichever way the condition
|
|
428
|
+
# goes. Reporting nothing there would turn a definite failure into a
|
|
429
|
+
# pass — and, under :strict, accept a result the schema rejects. Only
|
|
430
|
+
# a definite rejection counts on each side; what a branch could not
|
|
431
|
+
# evaluate leaves the conditional undecided as before.
|
|
432
|
+
# @param app [Application]
|
|
433
|
+
# @param cont [Proc] what follows the conditional
|
|
434
|
+
# @return [Array] a step
|
|
435
|
+
def unconditional_conditional(app, cont)
|
|
436
|
+
schema = app.schema
|
|
437
|
+
return cont.call unless schema.key?('then') && schema.key?('else')
|
|
438
|
+
|
|
439
|
+
undecided = app.ctx.undecided
|
|
440
|
+
[:apply, schema['then'], app.ref_depth, true, lambda do |then_errors, _evaluated|
|
|
441
|
+
next resume_conditional(app, undecided, cont) if then_errors.empty?
|
|
442
|
+
|
|
443
|
+
[:apply, schema['else'], app.ref_depth, true, lambda do |else_errors, _else_evaluated|
|
|
444
|
+
unless else_errors.empty?
|
|
445
|
+
app.errors << "#{app.path}: fails both then and else of an if this validator cannot decide " \
|
|
446
|
+
"(#{clip(then_errors.first.to_s)})"
|
|
447
|
+
end
|
|
448
|
+
resume_conditional(app, undecided, cont)
|
|
449
|
+
end, app.collecting]
|
|
450
|
+
end, app.collecting]
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Resume after measuring the branches: what they could not evaluate is
|
|
454
|
+
# not this node's uncertainty (the condition's already is).
|
|
455
|
+
# @return [Array] a step
|
|
456
|
+
def resume_conditional(app, undecided, cont)
|
|
457
|
+
app.ctx.undecided = undecided
|
|
458
|
+
cont.call
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The verdicts of a keyword's branches, and what each branch evaluated.
|
|
462
|
+
# Evaluation stops as soon as the branches seen so far settle the
|
|
463
|
+
# composition (`stop`): a branch that cannot change the outcome is not
|
|
464
|
+
# evaluated, so it cannot abort a decided validation. Once the
|
|
465
|
+
# composition is decided — early, or after every branch (`decided`) —
|
|
466
|
+
# the uncertainty count is restored to what it was before the branches
|
|
467
|
+
# ran.
|
|
468
|
+
# @param app [Application]
|
|
469
|
+
# @param subs [Array] the branches
|
|
470
|
+
# @return [Array] a step
|
|
471
|
+
def branch_verdicts(app, subs, stop:, decided:, &cont)
|
|
472
|
+
before = app.ctx.undecided
|
|
473
|
+
verdicts = []
|
|
474
|
+
evaluations = []
|
|
475
|
+
advance = nil
|
|
476
|
+
advance = lambda do
|
|
477
|
+
if verdicts.length >= subs.length || stop.call(verdicts)
|
|
478
|
+
app.ctx.undecided = before if stop.call(verdicts) || decided.call(verdicts)
|
|
479
|
+
next cont.call(verdicts, evaluations)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
branch_verdict(app, subs[verdicts.length]) do |verdict, evaluated|
|
|
483
|
+
verdicts << verdict
|
|
484
|
+
evaluations << evaluated
|
|
485
|
+
advance.call
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
advance.call
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# The verdict of a branch evaluated speculatively (its errors are a
|
|
492
|
+
# verdict, not output): :fail when a supported assertion rejected the
|
|
493
|
+
# value, :pass when every assertion was evaluated and accepted it,
|
|
494
|
+
# :undecided when it was accepted only as far as the validator could
|
|
495
|
+
# evaluate. Non-monotonic compositions (not, oneOf, if) never treat
|
|
496
|
+
# :undecided as a match. A definite verdict leaves no uncertainty
|
|
497
|
+
# behind: what a failing branch could not evaluate does not matter once
|
|
498
|
+
# it failed. The continuation also receives what the branch evaluated
|
|
499
|
+
# of the value (nothing, for a failed one).
|
|
500
|
+
# @param app [Application]
|
|
501
|
+
# @param sub [Object] the branch schema
|
|
502
|
+
# @return [Array] a step
|
|
503
|
+
def branch_verdict(app, sub, &cont)
|
|
504
|
+
ctx = app.ctx
|
|
505
|
+
before = ctx.undecided
|
|
506
|
+
[:apply, sub, app.ref_depth, true, lambda do |errors, evaluated|
|
|
507
|
+
unless errors.empty?
|
|
508
|
+
ctx.undecided = before
|
|
509
|
+
next cont.call(:fail, nil)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
cont.call(ctx.undecided == before ? :pass : :undecided, evaluated)
|
|
513
|
+
end, app.collecting]
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# What an input schema requires of every instance, read through the
|
|
6
|
+
# applicators that apply unconditionally: the root, what its `$ref`
|
|
7
|
+
# chain reaches, and each `allOf` member (each of those recursively).
|
|
8
|
+
# The tools specification says clients SHOULD follow `$ref` resolution
|
|
9
|
+
# when validating tool inputs, and SEP-2106 made `$ref` and `allOf` legal
|
|
10
|
+
# on an inputSchema: a `required` behind them is as required as one at
|
|
11
|
+
# the root. A branch the instance may or may not satisfy (`anyOf`,
|
|
12
|
+
# `oneOf`, `if`) decides nothing here and is left to the server, which
|
|
13
|
+
# MUST validate the arguments anyway. Extended into SchemaValidator, so
|
|
14
|
+
# the methods are its own.
|
|
15
|
+
module InputRequirements
|
|
16
|
+
# @param schema [Object] the input schema (string or symbol keys)
|
|
17
|
+
# @return [Array(Array<String>, Hash{String => Object})] the required
|
|
18
|
+
# property names, and the declared properties by name (a property
|
|
19
|
+
# declared nearer the root wins)
|
|
20
|
+
def input_requirements(schema)
|
|
21
|
+
root = normalize_schema(schema)
|
|
22
|
+
return [[], {}] unless root.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
declared = dialect(root)
|
|
25
|
+
scan = { count: 0, dialect: declared && canonical_dialect(declared), anchors: nil,
|
|
26
|
+
walked: {}.compare_by_identity, required: [], properties: {}, pending: [root] }
|
|
27
|
+
read_requirement_positions(root, scan)
|
|
28
|
+
[scan[:required].uniq, scan[:properties]]
|
|
29
|
+
rescue TooLarge
|
|
30
|
+
# Unusable anyway: the preflight reports it.
|
|
31
|
+
[[], {}]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Read every queued position, bounded like the preflight walk.
|
|
35
|
+
# @return [void]
|
|
36
|
+
def read_requirement_positions(root, scan)
|
|
37
|
+
until scan[:pending].empty?
|
|
38
|
+
node = scan[:pending].pop
|
|
39
|
+
next unless node.is_a?(Hash) && !scan[:walked].key?(node)
|
|
40
|
+
|
|
41
|
+
scan[:walked][node] = true
|
|
42
|
+
scan[:count] += 1
|
|
43
|
+
return if scan[:count] > MAX_SUBSCHEMAS
|
|
44
|
+
|
|
45
|
+
read_requirements(node, root, scan)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One position's requirements, and what it applies next. Under draft-07
|
|
50
|
+
# nothing beside a `$ref` is applied (draft-07 Core Section 8.3) — the
|
|
51
|
+
# draft-07 of the resource the position belongs to, which an embedded
|
|
52
|
+
# resource declares for itself (2020-12 Core Section 9.3.2), not the
|
|
53
|
+
# document root's.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def read_requirements(node, root, scan)
|
|
56
|
+
ref = node['$ref']
|
|
57
|
+
queue_referenced(node, ref, root, scan) if ref.is_a?(String)
|
|
58
|
+
return if position_dialect(node, root, scan) == DRAFT_07 && node.key?('$ref')
|
|
59
|
+
|
|
60
|
+
scan[:required].concat(node['required'].map(&:to_s)) if node['required'].is_a?(Array)
|
|
61
|
+
scan[:properties] = node['properties'].merge(scan[:properties]) if node['properties'].is_a?(Hash)
|
|
62
|
+
scan[:pending].concat(node['allOf'].reverse) if node['allOf'].is_a?(Array)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The dialect in force at a position: the one the anchor index recorded
|
|
66
|
+
# for its resource, else the root's.
|
|
67
|
+
# @return [String, nil]
|
|
68
|
+
def position_dialect(node, root, scan)
|
|
69
|
+
scan[:anchors] ||= anchor_index(root, scan[:dialect])
|
|
70
|
+
indexed_dialect(node, scan) || scan[:dialect]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Queue what a local reference reaches (an external one is never
|
|
74
|
+
# dereferenced, and an unresolvable one is the preflight's to report).
|
|
75
|
+
# @return [void]
|
|
76
|
+
def queue_referenced(node, ref, root, scan)
|
|
77
|
+
return if external_ref?(ref, root, scan[:dialect], scan, from: node)
|
|
78
|
+
|
|
79
|
+
target = resolve_reference(root, ref, scan[:dialect], scan, from: node)
|
|
80
|
+
scan[:pending] << target unless target.equal?(UNRESOLVED)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|