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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. 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