Module: MCPClient::SchemaValidator::Shapes

Included in:
MCPClient::SchemaValidator
Defined in:
lib/mcp_client/schema_validator/shapes.rb

Overview

The shape every keyword value must have before a schema can be used: an applicator must hold schemas, and an assertion this validator reads must hold what its keyword is defined to hold. A value of the wrong shape is neither ignored (that would turn an assertion into a pass) nor read as written (that would fail every instance): the schema is unusable, and the preflight says which keyword is at fault. Extended into SchemaValidator, so the methods are its own.

Constant Summary collapse

ASSERTION_SHAPES =

Assertion keywords whose value shape this validator reads, with what each must be. A malformed value is not silently ignored (that would turn an assertion into a pass) nor read as data (that would fail every instance): the schema is unusable, and the caller is told why.

{
  'type' => :type_names, 'enum' => :array, 'required' => :property_names, 'pattern' => :string,
  'minLength' => :non_negative_integer, 'maxLength' => :non_negative_integer,
  'minItems' => :non_negative_integer, 'maxItems' => :non_negative_integer,
  'minimum' => :number, 'maximum' => :number,
  'multipleOf' => :positive_number, 'uniqueItems' => :boolean,
  'minContains' => :non_negative_integer, 'maxContains' => :non_negative_integer,
  'minProperties' => :non_negative_integer, 'maxProperties' => :non_negative_integer,
  'dependentRequired' => :dependent_required
}.freeze
ANNOTATION_SHAPES =

The standard keywords that only annotate, with the type JSON Schema 2020-12 Validation Section 9 (and Section 8 for the content keywords) gives each. Annotating rather than asserting does not exempt a keyword from being written correctly: a schema that spells one wrong is a malformed document, and reading it as usable let :strict check results against a schema no validator could read.

{
  'title' => :string, 'description' => :string, '$comment' => :string, 'format' => :string,
  'contentEncoding' => :string, 'contentMediaType' => :string,
  'readOnly' => :boolean, 'writeOnly' => :boolean, 'deprecated' => :boolean, 'examples' => :array
}.freeze
KEYWORD_SHAPES =

Every keyword whose value shape the preflight reads.

ASSERTION_SHAPES.merge(ANNOTATION_SHAPES).freeze
JSON_TYPE_NAMES =

The JSON Schema type names (2020-12 Validation Section 6.1.1).

%w[array boolean integer null number object string].freeze
VALUE_SHAPES =

Each simple assertion shape: the predicate that admits a value, and how the requirement reads in a problem.

{
  array: [:array_value?, 'an array'],
  property_names: [:property_names?, 'an array of distinct property names'],
  string: [:string_value?, 'a string'],
  non_negative_integer: [:non_negative_integer?, 'a non-negative integer'],
  number: [:number_value?, 'a number'],
  positive_number: [:positive_number?, 'a number greater than zero'],
  boolean: [:boolean_value?, 'a boolean']
}.freeze

Instance Method Summary collapse

Instance Method Details

#absolute_uri?(value) ⇒ Boolean

Returns whether the value is a URI with a scheme.

Returns:

  • (Boolean) —

    whether the value is a URI with a scheme



254
255
256
257
258
# File 'lib/mcp_client/schema_validator/shapes.rb', line 254

def absolute_uri?(value)
  !URI::RFC3986_PARSER.parse(value).scheme.nil?
rescue URI::InvalidURIError
  false
end

#all_schemas?(values) ⇒ Boolean

Returns whether values is an array of schemas.

Parameters:

  • values (Object)

Returns:

  • (Boolean) —

    whether values is an array of schemas



314
315
316
# File 'lib/mcp_client/schema_validator/shapes.rb', line 314

def all_schemas?(values)
  values.is_a?(Array) && values.all? { |v| schema_value?(v) }
end

#applicator_shape_problem(keyword, value) ⇒ String?

Returns why an applicator value is malformed.

Returns:

  • (String, nil) —

    why an applicator value is malformed



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/mcp_client/schema_validator/shapes.rb', line 27

def applicator_shape_problem(keyword, value)
  if SUBSCHEMA_KEYWORDS.include?(keyword)
    tuple = keyword == 'items' && all_schemas?(value)
    "#{keyword} must be a schema" unless schema_value?(value) || tuple
  elsif keyword == 'dependencies'
    dependencies_shape_problem(value)
  elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword)
    # A definition bag holds reusable schemas, whatever a reference ends
    # up pointing at (JSON Schema 2020-12 Core Section 8.2.4).
    "#{keyword} must be an object of schemas" unless value.is_a?(Hash) && all_schemas?(value.values)
  elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword)
    # allOf / anyOf / oneOf / prefixItems: "MUST be a non-empty array".
    "#{keyword} must be a non-empty array of schemas" unless all_schemas?(value) && !value.empty?
  end
end

#array_value?(value) ⇒ Boolean

Returns:

  • (Boolean)


119
120
121
# File 'lib/mcp_client/schema_validator/shapes.rb', line 119

def array_value?(value)
  value.is_a?(Array)
end

#assertion_shape_problem(keyword, value) ⇒ String?

Returns why an assertion or annotation value is malformed.

Returns:

  • (String, nil) —

    why an assertion or annotation value is malformed



90
91
92
93
94
95
96
97
98
# File 'lib/mcp_client/schema_validator/shapes.rb', line 90

def assertion_shape_problem(keyword, value)
  shape = KEYWORD_SHAPES[keyword]
  return nil unless shape
  return type_shape_problem(value) if shape == :type_names
  return dependent_required_shape_problem(keyword, value) if shape == :dependent_required

  requirement = shape_requirement(shape, value)
  "#{keyword} must be #{requirement}" if requirement
end

#boolean_value?(value) ⇒ Boolean

Returns:

  • (Boolean)


144
145
146
# File 'lib/mcp_client/schema_validator/shapes.rb', line 144

def boolean_value?(value)
  [true, false].include?(value)
end

#check_applicator_shapes(schema, dialect, problems) ⇒ void

This method returns an undefined value.

Every applicator value must be a schema (object or boolean), an array of schemas or a map of schemas; anything else is not silently read as "true". Keywords the dialect does not define are ignored.



17
18
19
20
21
22
23
24
# File 'lib/mcp_client/schema_validator/shapes.rb', line 17

def check_applicator_shapes(schema, dialect, problems)
  schema.each do |keyword, value|
    next unless keyword_known?(keyword, dialect)

    problem = applicator_shape_problem(keyword, value)
    problems << problem if problem
  end
end

#check_assertion_shapes(schema, dialect, problems) ⇒ void

This method returns an undefined value.

Check the assertion keyword values a schema object carries. A keyword the dialect does not define (minContains under draft-07) is an unknown one there: ignored, never malformed.



80
81
82
83
84
85
86
87
# File 'lib/mcp_client/schema_validator/shapes.rb', line 80

def check_assertion_shapes(schema, dialect, problems)
  schema.each do |keyword, value|
    next unless keyword_known?(keyword, dialect)

    problem = assertion_shape_problem(keyword, value)
    problems << problem if problem
  end
end

#check_core_keyword_shapes(schema, dialect, problems) ⇒ void

This method returns an undefined value.

The core keywords with a fixed shape beyond the identifiers: $vocabulary is an object mapping vocabulary URIs to booleans (Core Section 8.1.2) and 2019-09's $recursiveAnchor is a boolean (2019-09 Core Section 8.2.4.2.2). A keyword the dialect does not define is unknown there, never malformed.



236
237
238
239
240
241
242
243
244
# File 'lib/mcp_client/schema_validator/shapes.rb', line 236

def check_core_keyword_shapes(schema, dialect, problems)
  if schema.key?('$vocabulary') && keyword_known?('$vocabulary',
                                                  dialect) && !vocabulary_map?(schema['$vocabulary'])
    problems << '$vocabulary must be an object of booleans keyed by URI'
  end
  return unless schema.key?('$recursiveAnchor') && keyword_known?('$recursiveAnchor', dialect)

  problems << '$recursiveAnchor must be a boolean' unless boolean_value?(schema['$recursiveAnchor'])
end

#check_exclusive_bounds(schema, _dialect, problems) ⇒ void

This method returns an undefined value.

exclusiveMinimum / exclusiveMaximum are numbers in every supported dialect (draft-07 validation Sections 6.2.3 and 6.2.5, kept by 2019-09 and 2020-12); the boolean modifier form belongs to draft-04, which is not supported, and is not silently ignored (it would turn a bound into a pass).



304
305
306
307
308
309
310
# File 'lib/mcp_client/schema_validator/shapes.rb', line 304

def check_exclusive_bounds(schema, _dialect, problems)
  %w[exclusiveMinimum exclusiveMaximum].each do |keyword|
    next if !schema.key?(keyword) || schema[keyword].is_a?(Numeric)

    problems << "#{keyword} must be a number (the draft-04 boolean form is not supported)"
  end
end

#check_identifier_shapes(schema, dialect, problems) ⇒ void

This method returns an undefined value.

An identifier must have the shape its dialect defines: $id is a URI reference without a non-empty fragment (JSON Schema 2020-12 Core Section 8.2.1) — draft-07 additionally spells a plain-name identifier as a bare fragment (Core Section 8.2.3) — and $anchor / $dynamicAnchor hold a plain name (Core Section 8.2.2). An identifier the validator cannot read names nothing, so a reference written to it would silently resolve elsewhere.



195
196
197
198
199
200
201
202
203
# File 'lib/mcp_client/schema_validator/shapes.rb', line 195

def check_identifier_shapes(schema, dialect, problems)
  id_problem = id_shape_problem(schema['$id'], dialect) if schema.key?('$id')
  problems << id_problem if id_problem
  %w[$anchor $dynamicAnchor].each do |keyword|
    next unless schema.key?(keyword) && keyword_known?(keyword, dialect)

    problems << "#{keyword} must be a plain name" unless anchor_name?(schema[keyword], dialect)
  end
end

#check_pattern_shapes(schema, dialect, problems, deadline = nil) ⇒ void

This method returns an undefined value.

A pattern, and every patternProperties key, must be an ECMA-262 regular expression the validator can read (JSON Schema 2020-12 Core Section 4.3) within the length bound. One that is not is a malformed keyword: the schema is unusable, not a schema without the pattern (which admitted every string).

Parameters:

  • deadline (Float, nil) (defaults to: nil) —

    monotonic deadline the check runs under



267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/mcp_client/schema_validator/shapes.rb', line 267

def check_pattern_shapes(schema, dialect, problems, deadline = nil)
  pattern = schema['pattern']
  problem = pattern_shape_problem('pattern', pattern, deadline) if pattern.is_a?(String)
  problems << problem if problem
  patterns = schema['patternProperties'] if keyword_known?('patternProperties', dialect)
  return unless patterns.is_a?(Hash)

  patterns.each_key do |key|
    break unless problems.empty?

    problem = pattern_shape_problem('patternProperties pattern', key.to_s, deadline)
    problems << problem if problem
  end
end

#dependencies_shape_problem(value) ⇒ String?

draft-07: each dependencies entry is a schema or an array of property names.

Returns:

  • (String, nil)


171
172
173
174
175
# File 'lib/mcp_client/schema_validator/shapes.rb', line 171

def dependencies_shape_problem(value)
  return if value.is_a?(Hash) && value.each_value.all? { |v| schema_value?(v) || property_names?(v) }

  'dependencies entries must be schemas or arrays of property names'
end

#dependent_required_shape_problem(keyword, value) ⇒ String?

dependentRequired maps a property name to the names it requires (JSON Schema 2020-12 Validation Section 6.5.4).

Returns:

  • (String, nil)


151
152
153
154
155
# File 'lib/mcp_client/schema_validator/shapes.rb', line 151

def dependent_required_shape_problem(keyword, value)
  return if value.is_a?(Hash) && value.each_value.all? { |v| property_names?(v) }

  "#{keyword} must be an object of arrays of distinct property names"
end

#id_shape_problem(id, dialect) ⇒ String?

Returns why an $id is malformed.

Returns:

  • (String, nil) —

    why an $id is malformed



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/mcp_client/schema_validator/shapes.rb', line 206

def id_shape_problem(id, dialect)
  return '$id must be a non-empty string' unless id.is_a?(String) && !id.empty?
  # JSON Schema 2020-12 Core Section 8.2.1: the value is a URI
  # reference. One that is not (a space in it, say) names no resource,
  # and a reference written to it would resolve elsewhere or nowhere.
  return "$id #{clip(id.inspect)} must be a URI reference" unless uri_reference?(id)
  # draft-07 Core Section 8.2.3: an $id that is exactly a fragment
  # declares a plain name rather than a base URI.
  return nil if dialect == DRAFT_07 && id.start_with?('#')

  fragment = id.split('#', 2)[1]
  return nil if fragment.nil? || fragment.empty?

  "$id #{clip(id.inspect)} must not contain a non-empty fragment"
end

#non_negative_integer?(value) ⇒ Boolean

Returns:

  • (Boolean)


139
140
141
# File 'lib/mcp_client/schema_validator/shapes.rb', line 139

def non_negative_integer?(value)
  integer?(value) && !value.negative?
end

#number_value?(value) ⇒ Boolean

Returns:

  • (Boolean)


129
130
131
# File 'lib/mcp_client/schema_validator/shapes.rb', line 129

def number_value?(value)
  value.is_a?(Numeric)
end

#pattern_shape_problem(keyword, pattern, deadline = nil) ⇒ String?

Returns why a pattern cannot be used.

Returns:

  • (String, nil) —

    why a pattern cannot be used



283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/mcp_client/schema_validator/shapes.rb', line 283

def pattern_shape_problem(keyword, pattern, deadline = nil)
  return "#{keyword} is longer than #{MAX_PATTERN_LENGTH} characters" if pattern.length > MAX_PATTERN_LENGTH
  return 'validation aborted: validation time budget exhausted during the schema check' if
    budget_exhausted?(deadline)

  ecma_regexp(pattern, PATTERN_MATCH_TIMEOUT, deadline)
  nil
rescue EcmaPatterns::Untranslatable => e
  "#{keyword} #{clip(pattern.inspect)} cannot be evaluated faithfully (#{clip(e.message)})"
rescue RegexpError => e
  "#{keyword} #{clip(pattern.inspect)} is not an ECMA-262 regular expression (#{clip(e.message)})"
rescue Aborted => e
  "validation aborted: #{e.message}"
end

#positive_number?(value) ⇒ Boolean

Returns:

  • (Boolean)


134
135
136
# File 'lib/mcp_client/schema_validator/shapes.rb', line 134

def positive_number?(value)
  value.is_a?(Numeric) && value.positive?
end

#property_names?(value) ⇒ Boolean

JSON Schema 2020-12 Validation Sections 6.5.3 and 6.5.4: the elements of required (and of a dependentRequired entry) are strings, and they MUST be unique — a name written twice is a malformed keyword, not the same assertion made again.

Parameters:

  • value (Object)

Returns:

  • (Boolean) —

    whether value is an array of distinct property names



183
184
185
# File 'lib/mcp_client/schema_validator/shapes.rb', line 183

def property_names?(value)
  value.is_a?(Array) && value.all?(String) && value.uniq.size == value.size
end

#shape_requirement(shape, value) ⇒ String?

Returns what a malformed value should have been.

Returns:

  • (String, nil) —

    what a malformed value should have been



113
114
115
116
# File 'lib/mcp_client/schema_validator/shapes.rb', line 113

def shape_requirement(shape, value)
  predicate, requirement = VALUE_SHAPES[shape]
  requirement unless predicate.nil? || send(predicate, value)
end

#string_value?(value) ⇒ Boolean

Returns:

  • (Boolean)


124
125
126
# File 'lib/mcp_client/schema_validator/shapes.rb', line 124

def string_value?(value)
  value.is_a?(String)
end

#type_shape_problem(value) ⇒ String?

Returns why a type value is malformed.

Returns:

  • (String, nil) —

    why a type value is malformed



158
159
160
161
162
163
164
165
166
# File 'lib/mcp_client/schema_validator/shapes.rb', line 158

def type_shape_problem(value)
  names = value.is_a?(Array) ? value : [value]
  known = !names.empty? && names.all? do |name|
    (name.is_a?(String) || name.is_a?(Symbol)) && JSON_TYPE_NAMES.include?(name.to_s)
  end
  return nil if known && (!value.is_a?(Array) || names.uniq.size == names.size)

  "type must be one of #{JSON_TYPE_NAMES.join(', ')}, or a non-empty array of distinct such names"
end

#uri_reference?(value) ⇒ Boolean

Returns whether a string parses as an RFC 3986 URI reference.

Returns:

  • (Boolean) —

    whether a string parses as an RFC 3986 URI reference



223
224
225
226
227
228
# File 'lib/mcp_client/schema_validator/shapes.rb', line 223

def uri_reference?(value)
  URI::RFC3986_PARSER.parse(value)
  true
rescue URI::InvalidURIError
  false
end

#vocabulary_map?(value) ⇒ Boolean

A vocabulary is identified by a URI (Core Section 8.1.2), never by a relative reference.

Returns:

  • (Boolean)


249
250
251
# File 'lib/mcp_client/schema_validator/shapes.rb', line 249

def vocabulary_map?(value)
  value.is_a?(Hash) && value.all? { |uri, required| absolute_uri?(uri.to_s) && boolean_value?(required) }
end