## 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.
95 lines
3 KiB
Ruby
95 lines
3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class Tool
|
|
class SchemaTest < ActiveSupport::TestCase
|
|
setup do
|
|
Schema::VALIDATION_CACHE.clear
|
|
end
|
|
|
|
test "validates a schema once and reuses the result for identical schemas" do
|
|
JSON::Validator.expects(:fully_validate).once.returns([])
|
|
|
|
schema = { properties: { validates_once: { type: "string" } } }
|
|
InputSchema.new(schema)
|
|
InputSchema.new(schema)
|
|
end
|
|
|
|
test "validates distinct schemas separately" do
|
|
JSON::Validator.expects(:fully_validate).twice.returns([])
|
|
|
|
InputSchema.new(properties: { distinct_a: { type: "string" } })
|
|
InputSchema.new(properties: { distinct_b: { type: "string" } })
|
|
end
|
|
|
|
test "a cache hit still yields a usable, validated schema" do
|
|
schema = { properties: { cache_hit: { type: "string" } }, required: ["cache_hit"] }
|
|
InputSchema.new(schema)
|
|
cached = InputSchema.new(schema)
|
|
|
|
assert_equal(
|
|
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
properties: { cache_hit: { type: "string" } },
|
|
required: ["cache_hit"],
|
|
},
|
|
cached.to_h,
|
|
)
|
|
assert_nil(cached.validate_arguments(cache_hit: "value"))
|
|
assert_raises(InputSchema::ValidationError) do
|
|
cached.validate_arguments(cache_hit: 123)
|
|
end
|
|
end
|
|
|
|
test "an invalid schema raises every time and is not cached" do
|
|
invalid = { properties: { not_cached: { type: "invalid_type" } } }
|
|
|
|
assert_raises(ArgumentError) { InputSchema.new(invalid) }
|
|
assert_raises(ArgumentError) { InputSchema.new(invalid) }
|
|
end
|
|
|
|
test "a schema at the normalization depth limit is cached without a nesting error" do
|
|
# The deepest schema the initializer can still normalize via JSON.dump/parse.
|
|
# The cache key must tolerate the same depth; the default JSON.generate
|
|
# nesting limit (100) is stricter than normalization and would raise here.
|
|
schema = { properties: { leaf: { type: "string" } } }
|
|
loop do
|
|
candidate = { properties: { child: schema } }
|
|
JSON.parse(JSON.dump(candidate))
|
|
schema = candidate
|
|
rescue JSON::NestingError
|
|
break
|
|
end
|
|
|
|
JSON::Validator.stub(:fully_validate, []) do
|
|
assert_nothing_raised do
|
|
InputSchema.new(schema)
|
|
InputSchema.new(schema)
|
|
end
|
|
end
|
|
end
|
|
|
|
test "ValidationCache evicts the oldest entry beyond its max size" do
|
|
cache = Schema::ValidationCache.new(max_size: 2)
|
|
cache.store("a")
|
|
cache.store("b")
|
|
cache.store("c")
|
|
|
|
refute cache.validated?("a")
|
|
assert cache.validated?("b")
|
|
assert cache.validated?("c")
|
|
end
|
|
|
|
test "ValidationCache#clear empties the cache" do
|
|
cache = Schema::ValidationCache.new
|
|
cache.store("a")
|
|
cache.clear
|
|
|
|
refute cache.validated?("a")
|
|
end
|
|
end
|
|
end
|
|
end
|