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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# The string and number keywords, and the budget a peer-supplied pattern
|
|
6
|
+
# is matched under. Both a pattern and the values it runs against come
|
|
7
|
+
# from the remote peer, so matching is bounded by the validation-wide
|
|
8
|
+
# deadline. Extended into SchemaValidator, so the methods are its own.
|
|
9
|
+
module Scalars
|
|
10
|
+
# Validate a string against minLength/maxLength/pattern.
|
|
11
|
+
# @param data [String] the string
|
|
12
|
+
# @param schema [Hash] string-keyed schema
|
|
13
|
+
# @param path [String] location for error messages
|
|
14
|
+
# @return [Array<String>] validation errors
|
|
15
|
+
def validate_string(data, schema, path, deadline = nil)
|
|
16
|
+
errors = []
|
|
17
|
+
min_length = schema['minLength']
|
|
18
|
+
max_length = schema['maxLength']
|
|
19
|
+
if min_length.is_a?(Numeric) && data.length < min_length
|
|
20
|
+
errors << "#{path}: string is shorter than minLength #{min_length}"
|
|
21
|
+
end
|
|
22
|
+
if max_length.is_a?(Numeric) && data.length > max_length
|
|
23
|
+
errors << "#{path}: string is longer than maxLength #{max_length}"
|
|
24
|
+
end
|
|
25
|
+
errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
|
|
26
|
+
errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Validate a string against a regular-expression pattern. A pattern
|
|
30
|
+
# that is no ECMA-262 expression is a malformed keyword the preflight
|
|
31
|
+
# refuses; should one reach here it is an error, never a pass.
|
|
32
|
+
#
|
|
33
|
+
# The pattern comes from the tool's outputSchema, i.e. from the remote
|
|
34
|
+
# server, so matching runs against the validation-wide deadline: neither a
|
|
35
|
+
# single expensive expression nor many cheap-looking ones can pin the
|
|
36
|
+
# calling thread. A match that exceeds the budget aborts the validation
|
|
37
|
+
# rather than silently accepting the value — the value was never shown
|
|
38
|
+
# to satisfy the schema.
|
|
39
|
+
# @param data [String] the string
|
|
40
|
+
# @param pattern [Object] the pattern keyword value
|
|
41
|
+
# @param path [String] location for error messages
|
|
42
|
+
# @param deadline [Float, nil] monotonic deadline for the whole validation
|
|
43
|
+
# @return [Array<String>] validation errors
|
|
44
|
+
# @raise [Aborted] when the budget is exhausted
|
|
45
|
+
def validate_pattern(data, pattern, path, deadline = nil)
|
|
46
|
+
return [] unless pattern.is_a?(String)
|
|
47
|
+
|
|
48
|
+
remaining = pattern_budget_remaining(deadline)
|
|
49
|
+
raise Aborted, "validation time budget exhausted before pattern #{clip(pattern.inspect)}" if remaining.zero?
|
|
50
|
+
|
|
51
|
+
return [] if data.match?(ecma_regexp(pattern, remaining, deadline))
|
|
52
|
+
|
|
53
|
+
["#{path}: string does not match pattern #{clip(pattern.inspect)}"]
|
|
54
|
+
rescue Regexp::TimeoutError
|
|
55
|
+
raise Aborted, "pattern #{clip(pattern.inspect)} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"
|
|
56
|
+
rescue EcmaPatterns::Untranslatable => e
|
|
57
|
+
["#{path}: pattern #{clip(pattern.inspect)} cannot be evaluated faithfully (#{clip(e.message)})"]
|
|
58
|
+
rescue RegexpError => e
|
|
59
|
+
["#{path}: pattern #{clip(pattern.inspect)} is not an ECMA-262 regular expression (#{clip(e.message)})"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Time left in the validation-wide budget.
|
|
63
|
+
# @param deadline [Float, nil] monotonic deadline, or nil for a lone match
|
|
64
|
+
# @return [Float] seconds available; 0.0 when exhausted
|
|
65
|
+
def pattern_budget_remaining(deadline)
|
|
66
|
+
return PATTERN_MATCH_TIMEOUT unless deadline
|
|
67
|
+
|
|
68
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
69
|
+
return 0.0 if remaining <= 0
|
|
70
|
+
|
|
71
|
+
[remaining, MIN_PATTERN_MATCH_TIMEOUT].max
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Validate a number against its bounds. `minimum` / `maximum` and
|
|
75
|
+
# `exclusiveMinimum` / `exclusiveMaximum` are four independent numeric
|
|
76
|
+
# assertions in every supported dialect (draft-07 validation Sections
|
|
77
|
+
# 6.2.2-6.2.5); each present one is applied.
|
|
78
|
+
# @param data [Numeric] the number
|
|
79
|
+
# @param schema [Hash] string-keyed schema
|
|
80
|
+
# @param path [String] location for error messages
|
|
81
|
+
# @return [Array<String>] validation errors
|
|
82
|
+
def validate_number(data, schema, path, _dialect = nil)
|
|
83
|
+
errors = []
|
|
84
|
+
minimum = schema['minimum']
|
|
85
|
+
maximum = schema['maximum']
|
|
86
|
+
exclusive_min = schema['exclusiveMinimum']
|
|
87
|
+
exclusive_max = schema['exclusiveMaximum']
|
|
88
|
+
shown = clip_value(data)
|
|
89
|
+
if minimum.is_a?(Numeric) && data < minimum
|
|
90
|
+
errors << "#{path}: value #{shown} is less than minimum #{clip_value(minimum)}"
|
|
91
|
+
end
|
|
92
|
+
if maximum.is_a?(Numeric) && data > maximum
|
|
93
|
+
errors << "#{path}: value #{shown} is greater than maximum #{clip_value(maximum)}"
|
|
94
|
+
end
|
|
95
|
+
if exclusive_min.is_a?(Numeric) && data <= exclusive_min
|
|
96
|
+
errors << "#{path}: value #{shown} must be greater than exclusiveMinimum #{clip_value(exclusive_min)}"
|
|
97
|
+
end
|
|
98
|
+
if exclusive_max.is_a?(Numeric) && data >= exclusive_max
|
|
99
|
+
errors << "#{path}: value #{shown} must be less than exclusiveMaximum #{clip_value(exclusive_max)}"
|
|
100
|
+
end
|
|
101
|
+
factor = schema['multipleOf']
|
|
102
|
+
if factor.is_a?(Numeric) && factor.positive? && !multiple_of?(data, factor)
|
|
103
|
+
errors << "#{path}: value #{shown} is not a multiple of #{clip_value(factor)}"
|
|
104
|
+
end
|
|
105
|
+
errors
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether dividing the value by the factor gives an integer (JSON
|
|
109
|
+
# Schema 2020-12 Validation Section 6.2.1). The division is exact:
|
|
110
|
+
# 0.0075 is a multiple of 0.0001, which binary floating point says it
|
|
111
|
+
# is not, so the decimal each number was written as decides.
|
|
112
|
+
# @param data [Numeric] the instance
|
|
113
|
+
# @param factor [Numeric] the multipleOf value
|
|
114
|
+
# @return [Boolean]
|
|
115
|
+
def multiple_of?(data, factor)
|
|
116
|
+
return (data % factor).zero? if data.is_a?(Integer) && factor.is_a?(Integer)
|
|
117
|
+
|
|
118
|
+
(Rational(data.to_s) / Rational(factor.to_s)).denominator == 1
|
|
119
|
+
rescue ArgumentError, ZeroDivisionError, FloatDomainError, TypeError
|
|
120
|
+
# A value no decimal describes (an infinity a Ruby caller passed in;
|
|
121
|
+
# JSON carries none) falls back to the floating-point remainder.
|
|
122
|
+
(data % factor).zero?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# The shape every keyword value must have before a schema can be used:
|
|
6
|
+
# an applicator must hold schemas, and an assertion this validator reads
|
|
7
|
+
# must hold what its keyword is defined to hold. A value of the wrong
|
|
8
|
+
# shape is neither ignored (that would turn an assertion into a pass)
|
|
9
|
+
# nor read as written (that would fail every instance): the schema is
|
|
10
|
+
# unusable, and the preflight says which keyword is at fault. Extended
|
|
11
|
+
# into SchemaValidator, so the methods are its own.
|
|
12
|
+
module Shapes
|
|
13
|
+
# Every applicator value must be a schema (object or boolean), an array
|
|
14
|
+
# of schemas or a map of schemas; anything else is not silently read as
|
|
15
|
+
# "true". Keywords the dialect does not define are ignored.
|
|
16
|
+
# @return [void]
|
|
17
|
+
def check_applicator_shapes(schema, dialect, problems)
|
|
18
|
+
schema.each do |keyword, value|
|
|
19
|
+
next unless keyword_known?(keyword, dialect)
|
|
20
|
+
|
|
21
|
+
problem = applicator_shape_problem(keyword, value)
|
|
22
|
+
problems << problem if problem
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String, nil] why an applicator value is malformed
|
|
27
|
+
def applicator_shape_problem(keyword, value)
|
|
28
|
+
if SUBSCHEMA_KEYWORDS.include?(keyword)
|
|
29
|
+
tuple = keyword == 'items' && all_schemas?(value)
|
|
30
|
+
"#{keyword} must be a schema" unless schema_value?(value) || tuple
|
|
31
|
+
elsif keyword == 'dependencies'
|
|
32
|
+
dependencies_shape_problem(value)
|
|
33
|
+
elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword)
|
|
34
|
+
# A definition bag holds reusable schemas, whatever a reference ends
|
|
35
|
+
# up pointing at (JSON Schema 2020-12 Core Section 8.2.4).
|
|
36
|
+
"#{keyword} must be an object of schemas" unless value.is_a?(Hash) && all_schemas?(value.values)
|
|
37
|
+
elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword)
|
|
38
|
+
# allOf / anyOf / oneOf / prefixItems: "MUST be a non-empty array".
|
|
39
|
+
"#{keyword} must be a non-empty array of schemas" unless all_schemas?(value) && !value.empty?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Assertion keywords whose value shape this validator reads, with what
|
|
44
|
+
# each must be. A malformed value is not silently ignored (that would
|
|
45
|
+
# turn an assertion into a pass) nor read as data (that would fail every
|
|
46
|
+
# instance): the schema is unusable, and the caller is told why.
|
|
47
|
+
ASSERTION_SHAPES = {
|
|
48
|
+
'type' => :type_names, 'enum' => :array, 'required' => :property_names, 'pattern' => :string,
|
|
49
|
+
'minLength' => :non_negative_integer, 'maxLength' => :non_negative_integer,
|
|
50
|
+
'minItems' => :non_negative_integer, 'maxItems' => :non_negative_integer,
|
|
51
|
+
'minimum' => :number, 'maximum' => :number,
|
|
52
|
+
'multipleOf' => :positive_number, 'uniqueItems' => :boolean,
|
|
53
|
+
'minContains' => :non_negative_integer, 'maxContains' => :non_negative_integer,
|
|
54
|
+
'minProperties' => :non_negative_integer, 'maxProperties' => :non_negative_integer,
|
|
55
|
+
'dependentRequired' => :dependent_required
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# The standard keywords that only annotate, with the type JSON Schema
|
|
59
|
+
# 2020-12 Validation Section 9 (and Section 8 for the content
|
|
60
|
+
# keywords) gives each. Annotating rather than asserting does not
|
|
61
|
+
# exempt a keyword from being written correctly: a schema that spells
|
|
62
|
+
# one wrong is a malformed document, and reading it as usable let
|
|
63
|
+
# :strict check results against a schema no validator could read.
|
|
64
|
+
ANNOTATION_SHAPES = {
|
|
65
|
+
'title' => :string, 'description' => :string, '$comment' => :string, 'format' => :string,
|
|
66
|
+
'contentEncoding' => :string, 'contentMediaType' => :string,
|
|
67
|
+
'readOnly' => :boolean, 'writeOnly' => :boolean, 'deprecated' => :boolean, 'examples' => :array
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
# Every keyword whose value shape the preflight reads.
|
|
71
|
+
KEYWORD_SHAPES = ASSERTION_SHAPES.merge(ANNOTATION_SHAPES).freeze
|
|
72
|
+
|
|
73
|
+
# The JSON Schema type names (2020-12 Validation Section 6.1.1).
|
|
74
|
+
JSON_TYPE_NAMES = %w[array boolean integer null number object string].freeze
|
|
75
|
+
|
|
76
|
+
# Check the assertion keyword values a schema object carries. A keyword
|
|
77
|
+
# the dialect does not define (`minContains` under draft-07) is an
|
|
78
|
+
# unknown one there: ignored, never malformed.
|
|
79
|
+
# @return [void]
|
|
80
|
+
def check_assertion_shapes(schema, dialect, problems)
|
|
81
|
+
schema.each do |keyword, value|
|
|
82
|
+
next unless keyword_known?(keyword, dialect)
|
|
83
|
+
|
|
84
|
+
problem = assertion_shape_problem(keyword, value)
|
|
85
|
+
problems << problem if problem
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String, nil] why an assertion or annotation value is malformed
|
|
90
|
+
def assertion_shape_problem(keyword, value)
|
|
91
|
+
shape = KEYWORD_SHAPES[keyword]
|
|
92
|
+
return nil unless shape
|
|
93
|
+
return type_shape_problem(value) if shape == :type_names
|
|
94
|
+
return dependent_required_shape_problem(keyword, value) if shape == :dependent_required
|
|
95
|
+
|
|
96
|
+
requirement = shape_requirement(shape, value)
|
|
97
|
+
"#{keyword} must be #{requirement}" if requirement
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Each simple assertion shape: the predicate that admits a value, and
|
|
101
|
+
# how the requirement reads in a problem.
|
|
102
|
+
VALUE_SHAPES = {
|
|
103
|
+
array: [:array_value?, 'an array'],
|
|
104
|
+
property_names: [:property_names?, 'an array of distinct property names'],
|
|
105
|
+
string: [:string_value?, 'a string'],
|
|
106
|
+
non_negative_integer: [:non_negative_integer?, 'a non-negative integer'],
|
|
107
|
+
number: [:number_value?, 'a number'],
|
|
108
|
+
positive_number: [:positive_number?, 'a number greater than zero'],
|
|
109
|
+
boolean: [:boolean_value?, 'a boolean']
|
|
110
|
+
}.freeze
|
|
111
|
+
|
|
112
|
+
# @return [String, nil] what a malformed value should have been
|
|
113
|
+
def shape_requirement(shape, value)
|
|
114
|
+
predicate, requirement = VALUE_SHAPES[shape]
|
|
115
|
+
requirement unless predicate.nil? || send(predicate, value)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def array_value?(value)
|
|
120
|
+
value.is_a?(Array)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def string_value?(value)
|
|
125
|
+
value.is_a?(String)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
def number_value?(value)
|
|
130
|
+
value.is_a?(Numeric)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def positive_number?(value)
|
|
135
|
+
value.is_a?(Numeric) && value.positive?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Boolean]
|
|
139
|
+
def non_negative_integer?(value)
|
|
140
|
+
integer?(value) && !value.negative?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Boolean]
|
|
144
|
+
def boolean_value?(value)
|
|
145
|
+
[true, false].include?(value)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# `dependentRequired` maps a property name to the names it requires
|
|
149
|
+
# (JSON Schema 2020-12 Validation Section 6.5.4).
|
|
150
|
+
# @return [String, nil]
|
|
151
|
+
def dependent_required_shape_problem(keyword, value)
|
|
152
|
+
return if value.is_a?(Hash) && value.each_value.all? { |v| property_names?(v) }
|
|
153
|
+
|
|
154
|
+
"#{keyword} must be an object of arrays of distinct property names"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [String, nil] why a `type` value is malformed
|
|
158
|
+
def type_shape_problem(value)
|
|
159
|
+
names = value.is_a?(Array) ? value : [value]
|
|
160
|
+
known = !names.empty? && names.all? do |name|
|
|
161
|
+
(name.is_a?(String) || name.is_a?(Symbol)) && JSON_TYPE_NAMES.include?(name.to_s)
|
|
162
|
+
end
|
|
163
|
+
return nil if known && (!value.is_a?(Array) || names.uniq.size == names.size)
|
|
164
|
+
|
|
165
|
+
"type must be one of #{JSON_TYPE_NAMES.join(', ')}, or a non-empty array of distinct such names"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# draft-07: each dependencies entry is a schema or an array of property
|
|
169
|
+
# names.
|
|
170
|
+
# @return [String, nil]
|
|
171
|
+
def dependencies_shape_problem(value)
|
|
172
|
+
return if value.is_a?(Hash) && value.each_value.all? { |v| schema_value?(v) || property_names?(v) }
|
|
173
|
+
|
|
174
|
+
'dependencies entries must be schemas or arrays of property names'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# JSON Schema 2020-12 Validation Sections 6.5.3 and 6.5.4: the elements
|
|
178
|
+
# of `required` (and of a `dependentRequired` entry) are strings, and
|
|
179
|
+
# they MUST be unique — a name written twice is a malformed keyword,
|
|
180
|
+
# not the same assertion made again.
|
|
181
|
+
# @param value [Object]
|
|
182
|
+
# @return [Boolean] whether value is an array of distinct property names
|
|
183
|
+
def property_names?(value)
|
|
184
|
+
value.is_a?(Array) && value.all?(String) && value.uniq.size == value.size
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# An identifier must have the shape its dialect defines: `$id` is a URI
|
|
188
|
+
# reference without a non-empty fragment (JSON Schema 2020-12 Core
|
|
189
|
+
# Section 8.2.1) — draft-07 additionally spells a plain-name identifier
|
|
190
|
+
# as a bare fragment (Core Section 8.2.3) — and `$anchor` /
|
|
191
|
+
# `$dynamicAnchor` hold a plain name (Core Section 8.2.2). An
|
|
192
|
+
# identifier the validator cannot read names nothing, so a reference
|
|
193
|
+
# written to it would silently resolve elsewhere.
|
|
194
|
+
# @return [void]
|
|
195
|
+
def check_identifier_shapes(schema, dialect, problems)
|
|
196
|
+
id_problem = id_shape_problem(schema['$id'], dialect) if schema.key?('$id')
|
|
197
|
+
problems << id_problem if id_problem
|
|
198
|
+
%w[$anchor $dynamicAnchor].each do |keyword|
|
|
199
|
+
next unless schema.key?(keyword) && keyword_known?(keyword, dialect)
|
|
200
|
+
|
|
201
|
+
problems << "#{keyword} must be a plain name" unless anchor_name?(schema[keyword], dialect)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @return [String, nil] why an `$id` is malformed
|
|
206
|
+
def id_shape_problem(id, dialect)
|
|
207
|
+
return '$id must be a non-empty string' unless id.is_a?(String) && !id.empty?
|
|
208
|
+
# JSON Schema 2020-12 Core Section 8.2.1: the value is a URI
|
|
209
|
+
# reference. One that is not (a space in it, say) names no resource,
|
|
210
|
+
# and a reference written to it would resolve elsewhere or nowhere.
|
|
211
|
+
return "$id #{clip(id.inspect)} must be a URI reference" unless uri_reference?(id)
|
|
212
|
+
# draft-07 Core Section 8.2.3: an $id that is exactly a fragment
|
|
213
|
+
# declares a plain name rather than a base URI.
|
|
214
|
+
return nil if dialect == DRAFT_07 && id.start_with?('#')
|
|
215
|
+
|
|
216
|
+
fragment = id.split('#', 2)[1]
|
|
217
|
+
return nil if fragment.nil? || fragment.empty?
|
|
218
|
+
|
|
219
|
+
"$id #{clip(id.inspect)} must not contain a non-empty fragment"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @return [Boolean] whether a string parses as an RFC 3986 URI reference
|
|
223
|
+
def uri_reference?(value)
|
|
224
|
+
URI::RFC3986_PARSER.parse(value)
|
|
225
|
+
true
|
|
226
|
+
rescue URI::InvalidURIError
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The core keywords with a fixed shape beyond the identifiers:
|
|
231
|
+
# `$vocabulary` is an object mapping vocabulary URIs to booleans (Core
|
|
232
|
+
# Section 8.1.2) and 2019-09's `$recursiveAnchor` is a boolean (2019-09
|
|
233
|
+
# Core Section 8.2.4.2.2). A keyword the dialect does not define is
|
|
234
|
+
# unknown there, never malformed.
|
|
235
|
+
# @return [void]
|
|
236
|
+
def check_core_keyword_shapes(schema, dialect, problems)
|
|
237
|
+
if schema.key?('$vocabulary') && keyword_known?('$vocabulary',
|
|
238
|
+
dialect) && !vocabulary_map?(schema['$vocabulary'])
|
|
239
|
+
problems << '$vocabulary must be an object of booleans keyed by URI'
|
|
240
|
+
end
|
|
241
|
+
return unless schema.key?('$recursiveAnchor') && keyword_known?('$recursiveAnchor', dialect)
|
|
242
|
+
|
|
243
|
+
problems << '$recursiveAnchor must be a boolean' unless boolean_value?(schema['$recursiveAnchor'])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# A vocabulary is identified by a URI (Core Section 8.1.2), never by a
|
|
247
|
+
# relative reference.
|
|
248
|
+
# @return [Boolean]
|
|
249
|
+
def vocabulary_map?(value)
|
|
250
|
+
value.is_a?(Hash) && value.all? { |uri, required| absolute_uri?(uri.to_s) && boolean_value?(required) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# @return [Boolean] whether the value is a URI with a scheme
|
|
254
|
+
def absolute_uri?(value)
|
|
255
|
+
!URI::RFC3986_PARSER.parse(value).scheme.nil?
|
|
256
|
+
rescue URI::InvalidURIError
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A `pattern`, and every `patternProperties` key, must be an ECMA-262
|
|
261
|
+
# regular expression the validator can read (JSON Schema 2020-12 Core
|
|
262
|
+
# Section 4.3) within the length bound. One that is not is a malformed
|
|
263
|
+
# keyword: the schema is unusable, not a schema without the pattern
|
|
264
|
+
# (which admitted every string).
|
|
265
|
+
# @param deadline [Float, nil] monotonic deadline the check runs under
|
|
266
|
+
# @return [void]
|
|
267
|
+
def check_pattern_shapes(schema, dialect, problems, deadline = nil)
|
|
268
|
+
pattern = schema['pattern']
|
|
269
|
+
problem = pattern_shape_problem('pattern', pattern, deadline) if pattern.is_a?(String)
|
|
270
|
+
problems << problem if problem
|
|
271
|
+
patterns = schema['patternProperties'] if keyword_known?('patternProperties', dialect)
|
|
272
|
+
return unless patterns.is_a?(Hash)
|
|
273
|
+
|
|
274
|
+
patterns.each_key do |key|
|
|
275
|
+
break unless problems.empty?
|
|
276
|
+
|
|
277
|
+
problem = pattern_shape_problem('patternProperties pattern', key.to_s, deadline)
|
|
278
|
+
problems << problem if problem
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @return [String, nil] why a pattern cannot be used
|
|
283
|
+
def pattern_shape_problem(keyword, pattern, deadline = nil)
|
|
284
|
+
return "#{keyword} is longer than #{MAX_PATTERN_LENGTH} characters" if pattern.length > MAX_PATTERN_LENGTH
|
|
285
|
+
return 'validation aborted: validation time budget exhausted during the schema check' if
|
|
286
|
+
budget_exhausted?(deadline)
|
|
287
|
+
|
|
288
|
+
ecma_regexp(pattern, PATTERN_MATCH_TIMEOUT, deadline)
|
|
289
|
+
nil
|
|
290
|
+
rescue EcmaPatterns::Untranslatable => e
|
|
291
|
+
"#{keyword} #{clip(pattern.inspect)} cannot be evaluated faithfully (#{clip(e.message)})"
|
|
292
|
+
rescue RegexpError => e
|
|
293
|
+
"#{keyword} #{clip(pattern.inspect)} is not an ECMA-262 regular expression (#{clip(e.message)})"
|
|
294
|
+
rescue Aborted => e
|
|
295
|
+
"validation aborted: #{e.message}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# exclusiveMinimum / exclusiveMaximum are numbers in every supported
|
|
299
|
+
# dialect (draft-07 validation Sections 6.2.3 and 6.2.5, kept by 2019-09
|
|
300
|
+
# and 2020-12); the boolean modifier form belongs to draft-04, which is
|
|
301
|
+
# not supported, and is not silently ignored (it would turn a bound
|
|
302
|
+
# into a pass).
|
|
303
|
+
# @return [void]
|
|
304
|
+
def check_exclusive_bounds(schema, _dialect, problems)
|
|
305
|
+
%w[exclusiveMinimum exclusiveMaximum].each do |keyword|
|
|
306
|
+
next if !schema.key?(keyword) || schema[keyword].is_a?(Numeric)
|
|
307
|
+
|
|
308
|
+
problems << "#{keyword} must be a number (the draft-04 boolean form is not supported)"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @param values [Object]
|
|
313
|
+
# @return [Boolean] whether values is an array of schemas
|
|
314
|
+
def all_schemas?(values)
|
|
315
|
+
values.is_a?(Array) && values.all? { |v| schema_value?(v) }
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
# URI reference resolution (RFC 3986 Section 5), used to decide which
|
|
6
|
+
# schema resource a `$ref` names. A JSON Schema document may bundle the
|
|
7
|
+
# resources it uses (JSON Schema 2020-12 Core Section 9.3.1): a reference
|
|
8
|
+
# written as an absolute URI, as a URI relative to the base an enclosing
|
|
9
|
+
# `$id` established, or as the empty reference then names a resource the
|
|
10
|
+
# document already carries, and resolving it needs no retrieval. Nothing
|
|
11
|
+
# here fetches or normalizes anything beyond what RFC 3986 defines, and
|
|
12
|
+
# nothing here raises: a reference a peer wrote that cannot be parsed
|
|
13
|
+
# simply names no local resource. Extended into SchemaValidator, so the
|
|
14
|
+
# methods are its own.
|
|
15
|
+
module UriReferences
|
|
16
|
+
# RFC 3986 Appendix B: the components of a URI reference.
|
|
17
|
+
URI_REFERENCE = %r{
|
|
18
|
+
\A
|
|
19
|
+
(?:(?<scheme>[A-Za-z][A-Za-z0-9+\-.]*):)?
|
|
20
|
+
(?://(?<authority>[^/?\#]*))?
|
|
21
|
+
(?<path>[^?\#]*)
|
|
22
|
+
(?:\?(?<query>[^\#]*))?
|
|
23
|
+
(?:\#(?<fragment>.*))?
|
|
24
|
+
\z
|
|
25
|
+
}mx
|
|
26
|
+
|
|
27
|
+
# Resolve a URI reference against a base URI (RFC 3986 Section 5.2.2),
|
|
28
|
+
# dropping the fragment: what comes back identifies a resource, which
|
|
29
|
+
# is what an `$id` declares and what a `$ref` selects before its
|
|
30
|
+
# fragment is applied.
|
|
31
|
+
# @param base [String] the base URI (may be empty when the document
|
|
32
|
+
# declares none: relative references still resolve consistently
|
|
33
|
+
# against each other)
|
|
34
|
+
# @param ref [String] the reference to resolve
|
|
35
|
+
# @return [String, nil] nil when either side is not a URI reference
|
|
36
|
+
def merge_uri(base, ref)
|
|
37
|
+
target = parse_uri_reference(ref)
|
|
38
|
+
origin = parse_uri_reference(base)
|
|
39
|
+
return nil unless target && origin
|
|
40
|
+
return merge_relative_uri(origin, target) unless target[:scheme]
|
|
41
|
+
|
|
42
|
+
compose_uri(target[:scheme], target[:authority], remove_dot_segments(target[:path]), target[:query])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The RFC 3986 Section 5.2.2 branches for a reference without a scheme.
|
|
46
|
+
# @param origin [Hash] the parsed base URI
|
|
47
|
+
# @param target [Hash] the parsed reference
|
|
48
|
+
# @return [String]
|
|
49
|
+
def merge_relative_uri(origin, target)
|
|
50
|
+
if target[:authority]
|
|
51
|
+
compose_uri(origin[:scheme], target[:authority], remove_dot_segments(target[:path]), target[:query])
|
|
52
|
+
elsif target[:path].empty?
|
|
53
|
+
compose_uri(origin[:scheme], origin[:authority], origin[:path], target[:query] || origin[:query])
|
|
54
|
+
elsif target[:path].start_with?('/')
|
|
55
|
+
compose_uri(origin[:scheme], origin[:authority], remove_dot_segments(target[:path]), target[:query])
|
|
56
|
+
else
|
|
57
|
+
path = remove_dot_segments(merge_paths(origin, target[:path]))
|
|
58
|
+
compose_uri(origin[:scheme], origin[:authority], path, target[:query])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param uri [String]
|
|
63
|
+
# @return [Hash, nil] :scheme, :authority, :query (each String or nil)
|
|
64
|
+
# and :path (always a String); nil when the text is not a URI
|
|
65
|
+
# reference (or is not readable text at all)
|
|
66
|
+
def parse_uri_reference(uri)
|
|
67
|
+
return nil unless uri.is_a?(String)
|
|
68
|
+
|
|
69
|
+
match = URI_REFERENCE.match(uri)
|
|
70
|
+
return nil unless match
|
|
71
|
+
|
|
72
|
+
# A scheme is case-insensitive (RFC 3986 Section 3.1), so two
|
|
73
|
+
# spellings of one resource identify the same one.
|
|
74
|
+
{ scheme: match[:scheme]&.downcase, authority: match[:authority], path: match[:path].to_s,
|
|
75
|
+
query: match[:query] }
|
|
76
|
+
rescue ArgumentError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# RFC 3986 Section 5.3.
|
|
81
|
+
# @return [String]
|
|
82
|
+
def compose_uri(scheme, authority, path, query)
|
|
83
|
+
composed = +''
|
|
84
|
+
composed << "#{scheme}:" if scheme
|
|
85
|
+
composed << "//#{authority}" if authority
|
|
86
|
+
composed << path
|
|
87
|
+
composed << "?#{query}" if query
|
|
88
|
+
composed
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# RFC 3986 Section 5.2.3: a relative path is merged onto the base's.
|
|
92
|
+
# @param origin [Hash] the parsed base URI
|
|
93
|
+
# @param path [String] the reference's relative path
|
|
94
|
+
# @return [String]
|
|
95
|
+
def merge_paths(origin, path)
|
|
96
|
+
return "/#{path}" if origin[:authority] && origin[:path].empty?
|
|
97
|
+
|
|
98
|
+
"#{origin[:path].sub(%r{[^/]*\z}, '')}#{path}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# RFC 3986 Section 5.2.4.
|
|
102
|
+
# @param path [String]
|
|
103
|
+
# @return [String]
|
|
104
|
+
def remove_dot_segments(path)
|
|
105
|
+
input = path
|
|
106
|
+
output = []
|
|
107
|
+
until input.empty?
|
|
108
|
+
stripped = strip_leading_dots(input)
|
|
109
|
+
unless stripped.equal?(input)
|
|
110
|
+
input = stripped
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
stepped = dot_segment_step(input, output)
|
|
115
|
+
if stepped
|
|
116
|
+
input = stepped
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
segment = input[%r{\A/?[^/]*}]
|
|
121
|
+
output << segment
|
|
122
|
+
input = input[segment.length..]
|
|
123
|
+
end
|
|
124
|
+
output.join
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The RFC 3986 Section 5.2.4 A and B prefixes, which are simply dropped.
|
|
128
|
+
# @return [String] the input itself when neither applies
|
|
129
|
+
def strip_leading_dots(input)
|
|
130
|
+
return input.sub(%r{\A\.\.?/}, '') if input.start_with?('../', './')
|
|
131
|
+
return '' if ['.', '..'].include?(input)
|
|
132
|
+
|
|
133
|
+
input
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The RFC 3986 Section 5.2.4 C and D prefixes, which also pop an
|
|
137
|
+
# already-emitted segment for "..".
|
|
138
|
+
# @return [String, false] the remaining input, or false when the
|
|
139
|
+
# prefix does not apply
|
|
140
|
+
def dot_segment_step(input, output)
|
|
141
|
+
return "/#{input[3..]}" if input.start_with?('/./')
|
|
142
|
+
return '/' if input == '/.'
|
|
143
|
+
|
|
144
|
+
if input.start_with?('/../') || input == '/..'
|
|
145
|
+
output.pop
|
|
146
|
+
return input == '/..' ? '/' : "/#{input[4..]}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|