Module: MCPClient::ElicitationValidator
- Defined in:
- lib/mcp_client/elicitation_validator.rb
Overview
Validates elicitation schemas and content per MCP 2025-11-25 spec. Schemas are restricted to flat objects with primitive property types:
string (with optional enum, pattern, format, minLength, maxLength)
number / integer (with optional minimum, maximum)
boolean
array (multi-select enum only, with items containing enum or anyOf)
Constant Summary collapse
- PRIMITIVE_TYPES =
Allowed primitive types for schema properties
%w[string number integer boolean].freeze
- STRING_FORMATS =
Allowed string formats per MCP spec
%w[email uri date date-time].freeze
- PATTERN_MATCH_TIMEOUT =
Wall-clock budget for ALL pattern matching in a single validate_content call. The requestedSchema comes from the remote server, so an expensive expression must not be able to monopolize the calling thread.
The budget covers the whole operation, not each match: a per-match limit multiplies, since the server also controls how many fields it declares.
1.0- MIN_PATTERN_MATCH_TIMEOUT =
Floor for an individual match, so a nearly-exhausted budget still makes progress rather than failing every remaining field.
0.01
Class Method Summary collapse
-
.parseable?(klass, value) ⇒ Boolean
Whether the value parses.
-
.pattern_budget_remaining(deadline) ⇒ Float
Time left in the validation-wide pattern budget.
-
.valid_uri?(value) ⇒ Boolean
Whether the value is an absolute URI.
-
.validate_array_property(name, prop) ⇒ Array<String>
Validate an array property (multi-select enum only).
-
.validate_array_value(field, value, prop) ⇒ Array<String>
Validate an array value against its property schema (multi-select enum).
-
.validate_content(content, schema) ⇒ Array<String>
Validate content against a requestedSchema.
-
.validate_number_value(field, value, prop) ⇒ Array<String>
Validate a number value against its property schema.
-
.validate_primitive_property(name, prop) ⇒ Array<String>
Validate a primitive property (string, number, integer, boolean).
-
.validate_property(name, prop) ⇒ Array<String>
Validate a single property definition.
-
.validate_schema(schema) ⇒ Array<String>
Validate that a requestedSchema conforms to MCP elicitation constraints.
-
.validate_string_format(field, value, format) ⇒ Array<String>
Validate a string value against the schema's format constraint.
-
.validate_string_pattern(field, value, pattern, deadline = nil) ⇒ Array<String>
Validate a string value against the schema's regular-expression pattern.
-
.validate_string_value(field, value, prop, deadline = nil) ⇒ Array<String>
Validate a string value against its property schema.
-
.validate_value(field, value, prop, deadline = nil) ⇒ Array<String>
Validate a single value against its property schema.
Class Method Details
.parseable?(klass, value) ⇒ Boolean
Returns whether the value parses.
282 283 284 285 286 287 |
# File 'lib/mcp_client/elicitation_validator.rb', line 282 def self.parseable?(klass, value) klass.iso8601(value) true rescue ArgumentError false end |
.pattern_budget_remaining(deadline) ⇒ Float
Time left in the validation-wide pattern budget.
243 244 245 246 247 248 249 250 |
# File 'lib/mcp_client/elicitation_validator.rb', line 243 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 |
.valid_uri?(value) ⇒ Boolean
Returns whether the value is an absolute URI.
272 273 274 275 276 277 |
# File 'lib/mcp_client/elicitation_validator.rb', line 272 def self.valid_uri?(value) uri = URI.parse(value) !uri.scheme.nil? rescue URI::InvalidURIError false end |
.validate_array_property(name, prop) ⇒ Array<String>
Validate an array property (multi-select enum only).
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/mcp_client/elicitation_validator.rb', line 107 def self.validate_array_property(name, prop) errors = [] items = prop['items'] unless items.is_a?(Hash) errors << "Property '#{name}' array type requires 'items' definition" return errors end has_enum = items['enum'].is_a?(Array) has_any_of = items['anyOf'].is_a?(Array) errors << "Property '#{name}' array items must have 'enum' or 'anyOf'" unless has_enum || has_any_of errors end |
.validate_array_value(field, value, prop) ⇒ Array<String>
Validate an array value against its property schema (multi-select enum).
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/mcp_client/elicitation_validator.rb', line 316 def self.validate_array_value(field, value, prop) errors = [] unless value.is_a?(Array) errors << "Field '#{field}' must be an array" return errors end items = prop['items'] || {} allowed = if items['enum'].is_a?(Array) items['enum'] elsif items['anyOf'].is_a?(Array) items['anyOf'].map { |o| o['const'] } end if allowed value.each do |v| errors << "Field '#{field}' contains invalid value '#{v}'" unless allowed.include?(v) end end if prop['minItems'] && value.length < prop['minItems'] errors << "Field '#{field}' must have at least #{prop['minItems']} items" end if prop['maxItems'] && value.length > prop['maxItems'] errors << "Field '#{field}' must have at most #{prop['maxItems']} items" end errors end |
.validate_content(content, schema) ⇒ Array<String>
Validate content against a requestedSchema. Returns an array of error messages (empty if valid).
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/mcp_client/elicitation_validator.rb', line 129 def self.validate_content(content, schema) errors = [] return errors unless content.is_a?(Hash) && schema.is_a?(Hash) # One deadline covers every field's pattern in this call. deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT properties = schema['properties'] || {} required = Array(schema['required']) # Check required fields required.each do |field| field_s = field.to_s errors << "Missing required field '#{field_s}'" unless content.key?(field_s) || content.key?(field_s.to_sym) end # Validate each provided field content.each do |field, value| prop = properties[field.to_s] next unless prop.is_a?(Hash) errors.concat(validate_value(field.to_s, value, prop, deadline)) end errors end |
.validate_number_value(field, value, prop) ⇒ Array<String>
Validate a number value against its property schema.
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
# File 'lib/mcp_client/elicitation_validator.rb', line 294 def self.validate_number_value(field, value, prop) errors = [] unless value.is_a?(Numeric) errors << "Field '#{field}' must be a number" return errors end errors << "Field '#{field}' must be an integer" if prop['type'] == 'integer' && !value.is_a?(Integer) errors << "Field '#{field}' must be >= #{prop['minimum']}" if prop['minimum'] && value < prop['minimum'] errors << "Field '#{field}' must be <= #{prop['maximum']}" if prop['maximum'] && value > prop['maximum'] errors end |
.validate_primitive_property(name, prop) ⇒ Array<String>
Validate a primitive property (string, number, integer, boolean).
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/mcp_client/elicitation_validator.rb', line 81 def self.validate_primitive_property(name, prop) errors = [] type = prop['type'] case type when 'string' if prop['format'] && !STRING_FORMATS.include?(prop['format']) errors << "Property '#{name}' has unsupported format '#{prop['format']}'" end errors << "Property '#{name}' enum must be an array" if prop['enum'] && !prop['enum'].is_a?(Array) when 'number', 'integer' if prop.key?('minimum') && !prop['minimum'].is_a?(Numeric) errors << "Property '#{name}' minimum must be numeric" end if prop.key?('maximum') && !prop['maximum'].is_a?(Numeric) errors << "Property '#{name}' maximum must be numeric" end end errors end |
.validate_property(name, prop) ⇒ Array<String>
Validate a single property definition.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/mcp_client/elicitation_validator.rb', line 60 def self.validate_property(name, prop) errors = [] return errors unless prop.is_a?(Hash) type = prop['type'] if type == 'array' errors.concat(validate_array_property(name, prop)) elsif PRIMITIVE_TYPES.include?(type) errors.concat(validate_primitive_property(name, prop)) else errors << "Property '#{name}' has unsupported type '#{type}'" end errors end |
.validate_schema(schema) ⇒ Array<String>
Validate that a requestedSchema conforms to MCP elicitation constraints. Returns an array of error messages (empty if valid).
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/mcp_client/elicitation_validator.rb', line 37 def self.validate_schema(schema) errors = [] return errors unless schema.is_a?(Hash) unless schema['type'] == 'object' errors << "Schema type must be 'object', got '#{schema['type']}'" return errors end properties = schema['properties'] return errors unless properties.is_a?(Hash) properties.each do |name, prop| errors.concat(validate_property(name, prop)) end errors end |
.validate_string_format(field, value, format) ⇒ Array<String>
Validate a string value against the schema's format constraint. The MCP elicitation schema supports email, uri, date, and date-time.
258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/mcp_client/elicitation_validator.rb', line 258 def self.validate_string_format(field, value, format) valid = case format when 'email' then value.match?(URI::MailTo::EMAIL_REGEXP) when 'uri' then valid_uri?(value) when 'date' then parseable?(Date, value) when 'date-time' then parseable?(Time, value) else true # No format declared, or an unknown format: not validated end valid ? [] : ["Field '#{field}' must be a valid #{format}"] end |
.validate_string_pattern(field, value, pattern, deadline = nil) ⇒ Array<String>
Validate a string value against the schema's regular-expression pattern. An invalid pattern is not enforced (unchanged behavior), but matching runs under PATTERN_MATCH_TIMEOUT because the pattern comes from the remote server. A match that exceeds the budget is reported as a validation error rather than silently accepted — the value was never shown to satisfy the constraint.
226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/mcp_client/elicitation_validator.rb', line 226 def self.validate_string_pattern(field, value, pattern, deadline = nil) remaining = pattern_budget_remaining(deadline) return ["Field '#{field}' pattern matching budget exhausted"] if remaining.zero? return [] if value.match?(Regexp.new(pattern, timeout: remaining)) ["Field '#{field}' must match pattern '#{pattern}'"] rescue Regexp::TimeoutError ["Field '#{field}' pattern '#{pattern}' exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"] rescue RegexpError # Skip pattern validation if the pattern is invalid [] end |
.validate_string_value(field, value, prop, deadline = nil) ⇒ Array<String>
Validate a string value against its property schema.
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/mcp_client/elicitation_validator.rb', line 184 def self.validate_string_value(field, value, prop, deadline = nil) errors = [] unless value.is_a?(String) errors << "Field '#{field}' must be a string" return errors end if prop['enum'].is_a?(Array) && !prop['enum'].include?(value) errors << "Field '#{field}' must be one of: #{prop['enum'].join(', ')}" end if prop['oneOf'].is_a?(Array) allowed = prop['oneOf'].map { |o| o['const'] } errors << "Field '#{field}' must be one of: #{allowed.join(', ')}" unless allowed.include?(value) end errors.concat(validate_string_pattern(field, value, prop['pattern'], deadline)) if prop['pattern'] if prop['minLength'] && value.length < prop['minLength'] errors << "Field '#{field}' must be at least #{prop['minLength']} characters" end if prop['maxLength'] && value.length > prop['maxLength'] errors << "Field '#{field}' must be at most #{prop['maxLength']} characters" end errors.concat(validate_string_format(field, value, prop['format'])) errors end |
.validate_value(field, value, prop, deadline = nil) ⇒ Array<String>
Validate a single value against its property schema.
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/mcp_client/elicitation_validator.rb', line 161 def self.validate_value(field, value, prop, deadline = nil) errors = [] type = prop['type'] case type when 'string' errors.concat(validate_string_value(field, value, prop, deadline)) when 'number', 'integer' errors.concat(validate_number_value(field, value, prop)) when 'boolean' errors << "Field '#{field}' must be a boolean" unless [true, false].include?(value) when 'array' errors.concat(validate_array_value(field, value, prop)) end errors end |