## Motivation and Context `MCP::Tool::Schema#initialize` validated every instance against the draft-04 metaschema via `JSON::Validator.fully_validate`, costing ~32ms for deep schemas (up to ~100ms). When schemas are built dynamically and repeatedly, this work is wasted because the result depends only on the schema content. This caches successful validations by a content digest, so an identical schema is validated once and subsequent constructions skip the traversal (~100ms to ~0.1ms on a cache hit). The cache is bounded and thread-safe. It supersedes the `validate: false` escape hatch proposed in #362, which would have added permanent public interface and allowed invalid schemas to reach clients. Python and TypeScript SDKs do not metaschema-validate schema definitions, so this cost is specific to the Ruby SDK; caching reduces it without weakening the default or changing validation semantics. ## How Has This Been Tested? Added regression tests in `test/mcp/tool/schema_test.rb`: identical schemas validate only once, distinct schemas validate separately, a cache hit still yields a usable and correctly validated schema, invalid schemas raise on every construction and are not cached, a schema at the normalization depth limit is cached without a nesting error, and `ValidationCache` evicts the oldest entry beyond its max size. The full suite (`rake test`) and `rake rubocop` pass. ## Breaking Changes None. Validation safety is preserved: every distinct schema is still validated, and invalid schemas continue to raise. The initializer signature and validation semantics are unchanged.
107 lines
3.6 KiB
Ruby
107 lines
3.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "digest"
|
|
require "json-schema"
|
|
|
|
module MCP
|
|
class Tool
|
|
class Schema
|
|
# Metaschema validation depends only on schema content, so a given schema
|
|
# never needs to be validated more than once. Caching the result lets repeated
|
|
# (e.g. dynamically rebuilt) schemas skip the costly traversal.
|
|
class ValidationCache
|
|
DEFAULT_MAX_SIZE = 1000
|
|
|
|
def initialize(max_size: DEFAULT_MAX_SIZE)
|
|
@max_size = max_size
|
|
@entries = {}
|
|
@mutex = Mutex.new
|
|
end
|
|
|
|
def validated?(key)
|
|
@mutex.synchronize { @entries.key?(key) }
|
|
end
|
|
|
|
def store(key)
|
|
@mutex.synchronize do
|
|
@entries.delete(key)
|
|
@entries[key] = true
|
|
@entries.shift while @entries.size > @max_size
|
|
end
|
|
end
|
|
|
|
def clear
|
|
@mutex.synchronize { @entries.clear }
|
|
end
|
|
end
|
|
VALIDATION_CACHE = ValidationCache.new
|
|
|
|
# JSON Schema 2020-12 is the default dialect for MCP schema definitions
|
|
# per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
|
|
# is still performed against the JSON Schema draft-04 metaschema because
|
|
# the `json-schema` gem does not yet support 2020-12.
|
|
JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"
|
|
|
|
attr_reader :schema
|
|
|
|
def initialize(schema = {})
|
|
@schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
|
|
@schema[:type] ||= "object"
|
|
validate_schema!
|
|
end
|
|
|
|
def ==(other)
|
|
other.is_a?(self.class) && schema == other.schema
|
|
end
|
|
|
|
def to_h
|
|
return @schema if @schema.key?(:"$schema")
|
|
|
|
{ "$schema": JSON_SCHEMA_2020_12_URI }.merge(@schema)
|
|
end
|
|
|
|
private
|
|
|
|
def fully_validate(data)
|
|
JSON::Validator.fully_validate(schema_for_validation, data)
|
|
end
|
|
|
|
def validate_schema!
|
|
target = schema_for_validation
|
|
|
|
# `max_nesting: false` because normalization uses `JSON.dump` (no nesting limit),
|
|
# so the default `JSON.generate` limit would raise on a deeply nested schema that
|
|
# the initializer already accepted.
|
|
key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false))
|
|
return if VALIDATION_CACHE.validated?(key)
|
|
|
|
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
|
schema_reader = JSON::Schema::Reader.new(
|
|
accept_uri: false,
|
|
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
|
)
|
|
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
|
# Converts metaschema to a file URI for cross-platform compatibility
|
|
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
|
metaschema = metaschema_uri.to_s
|
|
errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader)
|
|
if errors.any?
|
|
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
|
end
|
|
|
|
VALIDATION_CACHE.store(key)
|
|
end
|
|
|
|
# The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema`
|
|
# dialect URIs. Strip the top-level `$schema` before validation so a dialect URI
|
|
# (whether SDK-injected by `to_h` or user-supplied) does not break the validator.
|
|
def schema_for_validation
|
|
return @schema unless @schema.key?(:"$schema")
|
|
|
|
copy = @schema.dup
|
|
copy.delete(:"$schema")
|
|
copy
|
|
end
|
|
end
|
|
end
|
|
end
|