ruby-sdk/lib/mcp/tool/schema.rb
Koichi ITO 9d4dcbf1a4 Speed Up Tool::Schema Validation by 5x to 100x
## Motivation and Context

`MCP::Tool::Schema` previously used the pure-Ruby `json-schema` gem for
both construction-time metaschema validation and runtime argument /
result validation. For deep schemas this cost ~32-100ms per
construction (see issue #364).

`json_schemer` (https://rubygems.org/gems/json_schemer, v2.5.0) is
an actively maintained alternative that is much faster on this workload.
Switching reduces cold-start warming pressure in horizontally scaled
multi-tenant deployments (the scenario raised in #364) and brings
runtime data validation down to sub-millisecond per call once
the per-instance schemer is memoized.

The implementation preserves the legacy behavior of the `json-schema`
based code. Metaschema is pinned to draft-04 (`meta_schema:` option);
the `$schema` dialect URI is still emitted in `to_h` as 2020-12
but runtime validation continues to use draft-04, exactly as before.
Moving the runtime validator itself to 2020-12 is out of scope for
this PR and warrants its own discussion. `format` keywords are not enforced
(`format: false` option), matching what `json-schema`'s draft-04 path did.
Malformed schemas (e.g. an invalid `pattern` regex) continue to
surface as `ArgumentError, "Invalid JSON Schema: ..."`;
the `RegexpError` `json_schemer` raises during construction is wrapped.
`Symbol` values in argument data are coerced to strings inside
the internal `stringify` helper so they validate against `type: "string"`
the same way they did before.

The public `Schema#schema` accessor is dropped. It was a refactor
artifact from #198 (the consolidation of `InputSchema`'s
`properties` / `required` readers into a single hash) with no callers
in `lib/`; tests that needed the merged hash now read it through
`to_h`. The accessor's only remaining role would have been to expose
a mutation path that could desynchronize the memoized `@schemer`,
which this change removes.

Runtime dependency delta: drops `json-schema` (and `addressable`),
adds `json_schemer`, `hana`, `regexp_parser`, and `simpleidn`. Each added
gem is a single-purpose library tied to JSON Schema spec compliance.

Closes #364.

## How Has This Been Tested?

Side-by-side benchmark on representative schemas (simple, with `$ref` /
`additionalProperties`, nested, depth 20, depth 40):
construction-time metaschema validation is 4.7x to ~100x faster;
runtime data validation with the memoized schemer is sub-millisecond
across all sizes.

Four existing tests had their stub targets or message assertions updated
to track the new validator while preserving the original test intent.
The "unexpected errors bubble up" tests in `input_schema_test.rb`
and `output_schema_test.rb` now stub `JSONSchemer::Schema#validate`
instead of `JSON::Validator.fully_validate`. The cache tests in
`schema_test.rb` now stub `JSONSchemer::Schema#validate_schema`.
The two "detailed error message" tests in `tool_test.rb` switched from
asserting `json-schema`'s exact wording to format-agnostic substrings
(`"properties/count/minimum"`, `"number"`). The "required arguments are
converted to strings" test in `input_schema_test.rb` now reads the result
through `to_h[:required]` instead of the removed `schema` accessor.

Regression tests added for the legacy-behavior preservations above:
`format` keyword is not enforced, invalid `pattern` raises `ArgumentError`
(not `RegexpError`), and `Symbol` values validate against `type: "string"`.

## Breaking Changes

`Schema#schema` is no longer a public method. The accessor had no callers
outside the test suite, but anyone reading the internal representation
directly (rather than through `to_h`) will need to switch.
`Schema` / `InputSchema` / `OutputSchema` otherwise keep
their constructor signatures, `to_h` output, `ValidationError` /
`ArgumentError` raise points, and the `$schema` dialect emission.
The validator error message wording inside `ArgumentError` and
`ValidationError` now comes from `json_schemer` and still includes
the JSON pointer path and a description of the mismatch.
2026-06-01 21:47:30 +09:00

126 lines
4.1 KiB
Ruby

# frozen_string_literal: true
require "digest"
require "json_schemer"
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.
JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"
DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/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.instance_variable_get(:@schema)
end
def to_h
return @schema if @schema.key?(:"$schema")
{ "$schema": JSON_SCHEMA_2020_12_URI }.merge(@schema)
end
private
def stringify(obj)
case obj
when Hash
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
when Array
obj.map { |v| stringify(v) }
when Symbol
obj.to_s
else
obj
end
end
# Lazily built so a cache hit in `validate_schema!` avoids the schemer construction cost.
# Memoized per Schema instance because schema content is fixed at construction,
# so the compiled schemer is reusable across many `fully_validate` calls.
#
# `format: false` preserves the legacy behavior of the previous `json-schema` based implementation,
# which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as
# `ArgumentError` so callers see the same exception class they used to.
def schemer
@schemer ||= JSONSchemer.schema(
stringify(schema_for_validation),
meta_schema: DRAFT4_META_SCHEMA_URI,
format: false,
)
rescue RegexpError => e
raise ArgumentError, "Invalid JSON Schema: #{e.message}"
end
def fully_validate(data)
schemer.validate(stringify(data)).map { |validation_error| validation_error.fetch("error") }
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)
errors = schemer.validate_schema.map { |validation_error| validation_error.fetch("error") }
if errors.any?
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
end
VALIDATION_CACHE.store(key)
end
# `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation:
# this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas.
def schema_for_validation
return @schema unless @schema.key?(:"$schema")
copy = @schema.dup
copy.delete(:"$schema")
copy
end
end
end
end