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
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



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/mcp_client/schema_validator.rb', line 66

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)


138
139
140
141
142
143
# File 'lib/mcp_client/schema_validator.rb', line 138

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)


148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/mcp_client/schema_validator.rb', line 148

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

.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)


121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/mcp_client/schema_validator.rb', line 121

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



56
57
58
59
60
# File 'lib/mcp_client/schema_validator.rb', line 56

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

.validate(data, schema, path: '#') ⇒ 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)



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/mcp_client/schema_validator.rb', line 88

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

  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))
  when Array then errors.concat(validate_array(data, schema, path))
  when String then errors.concat(validate_string(data, schema, path))
  when Numeric then errors.concat(validate_number(data, schema, path))
  end
  errors
end

.validate_array(data, schema, path) ⇒ 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



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/mcp_client/schema_validator.rb', line 212

def self.validate_array(data, schema, path)
  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 { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) }
  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



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

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



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

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) ⇒ 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



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/mcp_client/schema_validator.rb', line 182

def self.validate_object(data, schema, path)
  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}"))
  end
  errors
end

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

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

Parameters:

  • data (String)

    the string

  • pattern (Object)

    the pattern keyword value

  • path (String)

    location for error messages

Returns:

  • (Array<String>)

    validation errors



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

def self.validate_pattern(data, pattern, path)
  return [] unless pattern.is_a?(String)
  return [] if data.match?(Regexp.new(pattern))

  ["#{path}: string does not match pattern #{pattern.inspect}"]
rescue RegexpError
  []
end

.validate_string(data, schema, path) ⇒ 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



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

def self.validate_string(data, schema, path)
  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))
  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



109
110
111
112
113
114
# File 'lib/mcp_client/schema_validator.rb', line 109

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