Skip to content

Commit 5c8b132

Browse files
committed
Cache Tool::Schema Validation to Avoid Re-validating Identical Schemas
## 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 modelcontextprotocol#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.
1 parent fd2fd27 commit 5c8b132

2 files changed

Lines changed: 137 additions & 1 deletion

File tree

lib/mcp/tool/schema.rb

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
11
# frozen_string_literal: true
22

3+
require "digest"
34
require "json-schema"
45

56
module MCP
67
class Tool
78
class Schema
9+
# Metaschema validation depends only on schema content, so a given schema
10+
# never needs to be validated more than once. Caching the result lets repeated
11+
# (e.g. dynamically rebuilt) schemas skip the costly traversal.
12+
class ValidationCache
13+
DEFAULT_MAX_SIZE = 1000
14+
15+
def initialize(max_size: DEFAULT_MAX_SIZE)
16+
@max_size = max_size
17+
@entries = {}
18+
@mutex = Mutex.new
19+
end
20+
21+
def validated?(key)
22+
@mutex.synchronize { @entries.key?(key) }
23+
end
24+
25+
def store(key)
26+
@mutex.synchronize do
27+
@entries.delete(key)
28+
@entries[key] = true
29+
@entries.shift while @entries.size > @max_size
30+
end
31+
end
32+
33+
def clear
34+
@mutex.synchronize { @entries.clear }
35+
end
36+
end
37+
VALIDATION_CACHE = ValidationCache.new
38+
839
# JSON Schema 2020-12 is the default dialect for MCP schema definitions
940
# per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
1041
# is still performed against the JSON Schema draft-04 metaschema because
@@ -36,6 +67,14 @@ def fully_validate(data)
3667
end
3768

3869
def validate_schema!
70+
target = schema_for_validation
71+
72+
# `max_nesting: false` because normalization uses `JSON.dump` (no nesting limit),
73+
# so the default `JSON.generate` limit would raise on a deeply nested schema that
74+
# the initializer already accepted.
75+
key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false))
76+
return if VALIDATION_CACHE.validated?(key)
77+
3978
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
4079
schema_reader = JSON::Schema::Reader.new(
4180
accept_uri: false,
@@ -45,10 +84,12 @@ def validate_schema!
4584
# Converts metaschema to a file URI for cross-platform compatibility
4685
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
4786
metaschema = metaschema_uri.to_s
48-
errors = JSON::Validator.fully_validate(metaschema, schema_for_validation, schema_reader: schema_reader)
87+
errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader)
4988
if errors.any?
5089
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
5190
end
91+
92+
VALIDATION_CACHE.store(key)
5293
end
5394

5495
# The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema`

test/mcp/tool/schema_test.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module MCP
6+
class Tool
7+
class SchemaTest < ActiveSupport::TestCase
8+
setup do
9+
Schema::VALIDATION_CACHE.clear
10+
end
11+
12+
test "validates a schema once and reuses the result for identical schemas" do
13+
JSON::Validator.expects(:fully_validate).once.returns([])
14+
15+
schema = { properties: { validates_once: { type: "string" } } }
16+
InputSchema.new(schema)
17+
InputSchema.new(schema)
18+
end
19+
20+
test "validates distinct schemas separately" do
21+
JSON::Validator.expects(:fully_validate).twice.returns([])
22+
23+
InputSchema.new(properties: { distinct_a: { type: "string" } })
24+
InputSchema.new(properties: { distinct_b: { type: "string" } })
25+
end
26+
27+
test "a cache hit still yields a usable, validated schema" do
28+
schema = { properties: { cache_hit: { type: "string" } }, required: ["cache_hit"] }
29+
InputSchema.new(schema)
30+
cached = InputSchema.new(schema)
31+
32+
assert_equal(
33+
{
34+
"$schema": "https://json-schema.org/draft/2020-12/schema",
35+
type: "object",
36+
properties: { cache_hit: { type: "string" } },
37+
required: ["cache_hit"],
38+
},
39+
cached.to_h,
40+
)
41+
assert_nil(cached.validate_arguments(cache_hit: "value"))
42+
assert_raises(InputSchema::ValidationError) do
43+
cached.validate_arguments(cache_hit: 123)
44+
end
45+
end
46+
47+
test "an invalid schema raises every time and is not cached" do
48+
invalid = { properties: { not_cached: { type: "invalid_type" } } }
49+
50+
assert_raises(ArgumentError) { InputSchema.new(invalid) }
51+
assert_raises(ArgumentError) { InputSchema.new(invalid) }
52+
end
53+
54+
test "a schema at the normalization depth limit is cached without a nesting error" do
55+
# The deepest schema the initializer can still normalize via JSON.dump/parse.
56+
# The cache key must tolerate the same depth; the default JSON.generate
57+
# nesting limit (100) is stricter than normalization and would raise here.
58+
schema = { properties: { leaf: { type: "string" } } }
59+
loop do
60+
candidate = { properties: { child: schema } }
61+
JSON.parse(JSON.dump(candidate))
62+
schema = candidate
63+
rescue JSON::NestingError
64+
break
65+
end
66+
67+
JSON::Validator.stub(:fully_validate, []) do
68+
assert_nothing_raised do
69+
InputSchema.new(schema)
70+
InputSchema.new(schema)
71+
end
72+
end
73+
end
74+
75+
test "ValidationCache evicts the oldest entry beyond its max size" do
76+
cache = Schema::ValidationCache.new(max_size: 2)
77+
cache.store("a")
78+
cache.store("b")
79+
cache.store("c")
80+
81+
refute cache.validated?("a")
82+
assert cache.validated?("b")
83+
assert cache.validated?("c")
84+
end
85+
86+
test "ValidationCache#clear empties the cache" do
87+
cache = Schema::ValidationCache.new
88+
cache.store("a")
89+
cache.clear
90+
91+
refute cache.validated?("a")
92+
end
93+
end
94+
end
95+
end

0 commit comments

Comments
 (0)