Module: MCPClient::SchemaValidator

Defined in:
lib/mcp_client/schema_validator.rb

Overview

Self-contained JSON Schema validator used to check a tool call result's structuredContent against the tool's declared outputSchema (MCP 2025-11-25 server/tools spec: "Clients SHOULD validate structured results against this schema"; the default schema dialect is JSON Schema 2020-12 per SEP-1613).

Only the common JSON Schema keywords are supported:

  • type (single value or array of values), enum, const
  • properties, required (objects)
  • items, minItems, maxItems (arrays)
  • minLength, maxLength, pattern (strings)
  • minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)

The full JSON Schema 2020-12 vocabulary ($ref/$defs, allOf/anyOf/oneOf/not, conditional keywords, additionalProperties, format assertions, ...) is out of scope: unrecognized keywords are ignored rather than misapplied, so validation is best-effort — it may accept data a full validator would reject, but it does not reject data that conforms to the schema. So that this gap is never silent, SchemaValidator.unsupported_keywords reports which unapplied validation keywords a schema uses; callers surface them as a warning.

Constant Summary collapse

UNSUPPORTED_KEYWORDS =

JSON Schema 2020-12 keywords that affect validation but that this validator does not evaluate: applicator/reference keywords, assertion keywords (multipleOf, uniqueItems, contains bounds, property-count bounds, dependentRequired), and format (asserted by full validators in format-assertion mode). Their presence means validation is partial: data may pass here that a full validator would reject.

%w[
  $ref $dynamicRef $defs allOf anyOf oneOf not if then else
  additionalProperties patternProperties propertyNames dependentSchemas
  prefixItems contains minContains maxContains uniqueItems
  multipleOf format dependentRequired minProperties maxProperties
  unevaluatedProperties unevaluatedItems
].freeze
PATTERN_MATCH_TIMEOUT =

Wall-clock budget for ALL pattern matching in a single validate call. Schemas come from the remote server, so an expensive expression must not be able to monopolize the calling thread.

The budget is for the whole operation, not per match: a per-match limit multiplies, since the server also controls how many strings it sends (N array items under one pathological items.pattern costs N x limit).

1.0
MIN_PATTERN_MATCH_TIMEOUT =

Floor for an individual match's timeout, so a nearly-exhausted budget still makes progress rather than failing every remaining pattern.

0.01
SUBSCHEMA_KEYWORDS =

Keywords whose value is a single subschema to walk.

%w[
  items contains additionalProperties propertyNames not if then else
  unevaluatedItems unevaluatedProperties
].freeze
SUBSCHEMA_MAP_KEYWORDS =

Keywords whose value is a map of name => subschema.

%w[properties patternProperties $defs definitions dependentSchemas].freeze
SUBSCHEMA_ARRAY_KEYWORDS =

Keywords whose value is an array of subschemas.

%w[allOf anyOf oneOf prefixItems].freeze

Class Method Summary collapse

Class Method Details

.collect_unsupported_keywords(schema, found) ⇒ void

This method returns an undefined value.

Recursively collect unsupported keywords from a schema.

Parameters:

  • schema (Object)

    a (sub)schema; non-Hash values are ignored

  • found (Array<String>)

    accumulator



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/mcp_client/schema_validator.rb', line 79

def self.collect_unsupported_keywords(schema, found)
  return unless schema.is_a?(Hash)

  schema = schema.transform_keys(&:to_s)
  found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
  schema.each do |keyword, value|
    if SUBSCHEMA_KEYWORDS.include?(keyword)
      collect_unsupported_keywords(value, found)
    elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
      value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
    elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
      value.each { |subschema| collect_unsupported_keywords(subschema, found) }
    end
  end
end

.integer?(data) ⇒ Boolean

Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a number with a zero fractional part (e.g. 2.0) is a valid integer.

Parameters:

  • data (Object)

    the value

Returns:

  • (Boolean)


154
155
156
157
158
159
# File 'lib/mcp_client/schema_validator.rb', line 154

def self.integer?(data)
  return true if data.is_a?(Integer)
  return false unless data.is_a?(Numeric)

  (data % 1).zero?
end

.json_type(data) ⇒ String

The JSON type name of a Ruby value (for error messages).

Parameters:

  • data (Object)

    the value

Returns:

  • (String)


164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/mcp_client/schema_validator.rb', line 164

def self.json_type(data)
  case data
  when nil then 'null'
  when true, false then 'boolean'
  when Integer then 'integer'
  when Numeric then 'number'
  when String then 'string'
  when Array then 'array'
  when Hash then 'object'
  else data.class.name
  end
end

.pattern_budget_remaining(deadline) ⇒ Float

Time left in the validation-wide pattern budget.

Parameters:

  • deadline (Float, nil)

    monotonic deadline, or nil for a lone match

Returns:

  • (Float)

    seconds available for the next match; 0.0 when exhausted



298
299
300
301
302
303
304
305
# File 'lib/mcp_client/schema_validator.rb', line 298

def self.pattern_budget_remaining(deadline)
  return PATTERN_MATCH_TIMEOUT unless deadline

  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
  return 0.0 if remaining <= 0

  [remaining, MIN_PATTERN_MATCH_TIMEOUT].max
end

.type_match?(type, data) ⇒ Boolean

Whether a value matches a JSON Schema type name. Unknown type names are not enforced (returns true).

Parameters:

  • type (String)

    the JSON Schema type name

  • data (Object)

    the value

Returns:

  • (Boolean)


137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/mcp_client/schema_validator.rb', line 137

def self.type_match?(type, data)
  case type
  when 'object' then data.is_a?(Hash)
  when 'array' then data.is_a?(Array)
  when 'string' then data.is_a?(String)
  when 'boolean' then data.equal?(true) || data.equal?(false)
  when 'null' then data.nil?
  when 'number' then data.is_a?(Numeric)
  when 'integer' then integer?(data)
  else true
  end
end

.unsupported_keywords(schema) ⇒ Array<String>

List the unsupported JSON Schema keywords a schema uses (anywhere: at the top level or nested in subschemas). Property names that merely look like keywords (e.g. a property called 'not') are not reported, and data-carrying keywords (enum/const/default/examples) are not scanned.

Parameters:

  • schema (Object)

    the JSON schema (string or symbol keys)

Returns:

  • (Array<String>)

    unique unsupported keywords, in discovery order



69
70
71
72
73
# File 'lib/mcp_client/schema_validator.rb', line 69

def self.unsupported_keywords(schema)
  found = []
  collect_unsupported_keywords(schema, found)
  found.uniq
end

.validate(data, schema, path: '#', deadline: nil) ⇒ Array<String>

Validate data against a JSON Schema subset. Schema and data hashes may use string or symbol keys.

Parameters:

  • data (Object)

    the value to validate

  • schema (Hash)

    the JSON schema

  • path (String) (defaults to: '#')

    JSON-pointer-style location used in error messages

Returns:

  • (Array<String>)

    human-readable validation errors (empty if valid)



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/mcp_client/schema_validator.rb', line 101

def self.validate(data, schema, path: '#', deadline: nil)
  return [] unless schema.is_a?(Hash)

  # One deadline covers the entire (recursive) validation.
  deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT

  schema = schema.transform_keys(&:to_s)
  errors = []
  errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
  errors.concat(validate_enum(data, schema, path))
  case data
  when Hash then errors.concat(validate_object(data, schema, path, deadline))
  when Array then errors.concat(validate_array(data, schema, path, deadline))
  when String then errors.concat(validate_string(data, schema, path, deadline))
  when Numeric then errors.concat(validate_number(data, schema, path))
  end
  errors
end

.validate_array(data, schema, path, deadline = nil) ⇒ Array<String>

Validate an array against items/minItems/maxItems.

Parameters:

  • data (Array)

    the array

  • schema (Hash)

    string-keyed schema

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/mcp_client/schema_validator.rb', line 228

def self.validate_array(data, schema, path, deadline = nil)
  errors = []
  min_items = schema['minItems']
  max_items = schema['maxItems']
  if min_items.is_a?(Numeric) && data.length < min_items
    errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
  end
  if max_items.is_a?(Numeric) && data.length > max_items
    errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
  end
  items = schema['items']
  if items.is_a?(Hash)
    data.each_with_index do |item, idx|
      errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline))
    end
  end
  errors
end

.validate_enum(data, schema, path) ⇒ Array<String>

Validate enum/const membership.

Parameters:

  • data (Object)

    the value

  • schema (Hash)

    string-keyed schema

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



182
183
184
185
186
187
188
189
190
191
# File 'lib/mcp_client/schema_validator.rb', line 182

def self.validate_enum(data, schema, path)
  errors = []
  if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
    errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
  end
  if schema.key?('const') && schema['const'] != data
    errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
  end
  errors
end

.validate_number(data, schema, path) ⇒ Array<String>

Validate a number against inclusive/exclusive bounds.

Parameters:

  • data (Numeric)

    the number

  • schema (Hash)

    string-keyed schema

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/mcp_client/schema_validator.rb', line 312

def self.validate_number(data, schema, path)
  errors = []
  minimum = schema['minimum']
  maximum = schema['maximum']
  exclusive_min = schema['exclusiveMinimum']
  exclusive_max = schema['exclusiveMaximum']
  errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
  errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
  if exclusive_min.is_a?(Numeric) && data <= exclusive_min
    errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
  end
  if exclusive_max.is_a?(Numeric) && data >= exclusive_max
    errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
  end
  errors
end

.validate_object(data, schema, path, deadline = nil) ⇒ Array<String>

Validate an object against required/properties.

Parameters:

  • data (Hash)

    the object

  • schema (Hash)

    string-keyed schema

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/mcp_client/schema_validator.rb', line 198

def self.validate_object(data, schema, path, deadline = nil)
  errors = []
  Array(schema['required']).each do |raw_name|
    name = raw_name.to_s
    errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
  end
  properties = schema['properties']
  return errors unless properties.is_a?(Hash)

  properties.each do |raw_name, prop_schema|
    next unless prop_schema.is_a?(Hash)

    name = raw_name.to_s
    key = if data.key?(name)
            name
          elsif data.key?(name.to_sym)
            name.to_sym
          end
    next if key.nil?

    errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline))
  end
  errors
end

.validate_pattern(data, pattern, path, deadline = nil) ⇒ Array<String>

Validate a string against a regular-expression pattern. Invalid patterns are not enforced.

The pattern comes from the tool's outputSchema, i.e. from the remote server, so matching runs against the validation-wide deadline: neither a single expensive expression nor many cheap-looking ones can pin the calling thread. A match that exceeds the budget is reported as a validation error rather than silently accepted — the value was never shown to satisfy the schema.

Parameters:

  • data (String)

    the string

  • pattern (Object)

    the pattern keyword value

  • path (String)

    location for error messages

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

    monotonic deadline for the whole validation

Returns:

  • (Array<String>)

    validation errors



280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/mcp_client/schema_validator.rb', line 280

def self.validate_pattern(data, pattern, path, deadline = nil)
  return [] unless pattern.is_a?(String)

  remaining = pattern_budget_remaining(deadline)
  return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero?

  return [] if data.match?(Regexp.new(pattern, timeout: remaining))

  ["#{path}: string does not match pattern #{pattern.inspect}"]
rescue Regexp::TimeoutError
  ["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
rescue RegexpError
  []
end

.validate_string(data, schema, path, deadline = nil) ⇒ Array<String>

Validate a string against minLength/maxLength/pattern.

Parameters:

  • data (String)

    the string

  • schema (Hash)

    string-keyed schema

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/mcp_client/schema_validator.rb', line 252

def self.validate_string(data, schema, path, deadline = nil)
  errors = []
  min_length = schema['minLength']
  max_length = schema['maxLength']
  if min_length.is_a?(Numeric) && data.length < min_length
    errors << "#{path}: string is shorter than minLength #{min_length}"
  end
  if max_length.is_a?(Numeric) && data.length > max_length
    errors << "#{path}: string is longer than maxLength #{max_length}"
  end
  errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
  errors
end

.validate_type(data, type, path) ⇒ Array<String>

Validate the JSON type of a value.

Parameters:

  • data (Object)

    the value

  • type (String, Symbol, Array<String, Symbol>)

    expected type(s)

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



125
126
127
128
129
130
# File 'lib/mcp_client/schema_validator.rb', line 125

def self.validate_type(data, type, path)
  types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
  return [] if types.any? { |t| type_match?(t, data) }

  ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
end