Commit graph ruby-sdk/lib/mcp/tool/schema.rb
Author SHA1 Message Date
Koichi ITO
5ca8b6ebbb Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106
## Motivation and Context

SEP-2106 (modelcontextprotocol/modelcontextprotocol#2106, merged for the 2026-07-28 spec release)
makes tool `inputSchema` and `outputSchema` conform to the full JSON Schema 2020-12 vocabulary:
an input schema keeps `type: "object"` at the root but may use any 2020-12 keyword below it;
an output schema may be ANY valid schema (object, array, primitive, or a root-level composition);
and `CallToolResult.structuredContent` widens from an object to any JSON value. The SEP also adds
resource bounds: `$ref` resolution is restricted (same-document only in the reference implementation) and
composition-heavy documents must be bounded to avoid excessive validation cost.

This follows the TypeScript SDK's reference implementation (typescript-sdk#2249; the Python SDK tracks
the same work in python-sdk#2792):

- `Tool::Schema` now validates against the JSON Schema 2020-12 metaschema rather than the draft-04 metaschema.
  The draft-04 pin was a stopgap from when the SDK used the `json-schema` gem, which did not support 2020-12;
  `json_schemer` does, so `$defs`/`$ref` and the rest of the 2020-12 vocabulary now resolve natively.
  This matches the dialect the SDK already advertises in emitted schemas and the Python SDK's behavior,
   whose `jsonschema.validate` selects the validator from the schema's `$schema`.
- `Tool::Schema` moves root-type defaulting into an overridable `apply_default_root_type!` hook.
  `InputSchema` keeps the historical `type: "object"` default; `OutputSchema` now applies it only
  when no root schema keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`) is present.
  The previous unconditional default merged `type: "object"` into root combinators such as `{ oneOf: [...] }`,
  producing a wrong schema, so that case is a bug fix.
- `Tool::Schema` enforces the TypeScript SDK's schema bounds at construction time: only same-document
  `$ref`/`$dynamicRef`s (starting with `#`, so schema handling can never trigger network or file access),
  `MAX_SCHEMA_DEPTH = 64` nesting levels, and `MAX_SUBSCHEMA_COUNT = 10_000` subschema objects,
  all raising `ArgumentError` on violation.
- `Server#call_tool` mirrors non-object `structuredContent` into `content` as serialized JSON text
  when the tool provided no content blocks, so pre-SEP clients that only read `content` still receive the data.
  Object results and explicit content are untouched.

Resolves #377.

## How Has This Been Tested?

- `test/mcp/tool/output_schema_test.rb`: root-level `oneOf`, `$ref`+`$defs`, primitive, and `enum` schemas serialize
  without an injected `type` and validate results correctly; the `properties`-only shorthand still serializes
  with `type: "object"` (wire-format regression); explicit `type: "array"` keeps working.
- `test/mcp/tool/input_schema_test.rb`: an input schema using `$defs`, `$ref`, `oneOf`, `if`/`then`, and `allOf`
  keeps its object root and round-trips all keywords; a draft-04-only boolean `exclusiveMinimum` is rejected
  under the 2020-12 dialect while the numeric form is accepted.
- `test/mcp/tool/schema_test.rb`: depth and subschema-count bound violations raise `ArgumentError`;
  non-same-document `$ref`s (remote URI, sibling file) are rejected while `#/$defs/...` is accepted.
  The previous unbounded-depth caching test is replaced, since the depth bound now rejects such documents by design.
- `test/mcp/server_test.rb`: `tools/call` with array `structuredContent` and no content gains
  the serialized TextContent fallback; explicit content is not overwritten; object `structuredContent` gets no fallback.

`bundle exec rake` (tests, RuboCop, and conformance baseline, including the `json-schema-2020-12` server scenario) passes.

## Breaking Changes

Three narrow behavior changes, all intentional per the SEP:

- Runtime validation now uses the JSON Schema 2020-12 metaschema instead of draft-04.
  Schemas that rely on draft-04-only syntax are rejected at construction time. The practical case is the boolean
  `exclusiveMinimum`/`exclusiveMaximum` form (deprecated since draft-06), which must now be the numeric form;
  the Python SDK rejects it the same way. Other draft-04 spellings (`definitions`, `id`, `dependencies`) still validate,
  since 2020-12 tolerates unknown keywords.
- Schemas that exceed the new resource bounds (nesting deeper than 64, more than 10,000 subschema objects) or
  use a non-same-document `$ref`/`$dynamicRef` now raise `ArgumentError` at construction time.
  Previously such documents were accepted (external references were already never fetched, only ignored).
- An `OutputSchema` whose root declares a schema keyword other than `type` (e.g. `oneOf`) no longer has  `type: "object"`
  merged into it. The old output was an invalid hybrid schema, so no conforming consumer could have relied on it.
2026-06-19 21:36:55 +09:00
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
Koichi ITO
5c8b132dcd 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 #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.
2026-05-27 12:38:28 +09:00
Koichi ITO
1fc698bb8e Advertise JSON Schema 2020-12 dialect on emitted tool schemas
## Motivation and Context

MCP 2025-11-25 (SEP-1613) establishes JSON Schema 2020-12 as the default dialect
for MCP schema definitions.
 https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1613

The Ruby SDK was emitting tool input and output schemas with no `$schema` field,
leaving the dialect implicit and inconsistent with the TypeScript SDK
(`packages/core/src/util/standardSchema.ts`, which emits with `target: 'draft-2020-12'`)
and the Python SDK (which preserves `$schema` in its types and conformance fixtures).

`Tool::Schema#to_h` now includes `"$schema": "https://json-schema.org/draft/2020-12/schema"`
unless the user supplied a different `$schema` value, so clients see the canonical dialect URI
on the wire.

Runtime metaschema and argument validation remain on draft-04 because the `json-schema` gem
does not yet support 2020-12. Switching the runtime validator is left as a separate change
because it requires either replacing the gem or waiting for upstream 2020-12 support.

## How Has This Been Tested?

Updated `Tool::Schema#to_h` expectations across `input_schema_test`, `output_schema_test`,
`tool_test`, and the `tools/list` server tests to reflect the new `$schema` field.
Added focused regression tests:

- `to_h` preserves a user-supplied `$schema` URI rather than overwriting it
  (covered for both symbol-keyed input and the string-keyed Hash form that
  goes through the initializer's `symbolize_names` normalization)
- `validate_arguments` / `validate_result` continue to work when the user supplies
  a 2020-12 `$schema` URI on the schema (the validator strips the unknown top-level
  dialect URI before invoking the draft-04 validator, instead of raising `JSON::Schema::SchemaError`)
- `tools/list` JSON-RPC response includes the 2020-12 `$schema` on both `inputSchema`
  and `outputSchema` (transport-boundary assertion)

`bundle exec rake test` and `bundle exec rake rubocop` both pass.

## Breaking Changes

Additive change. Tolerant consumers of the `tools/list` payload are unaffected.
Consumers that strictly assert hash equality on the emitted `inputSchema` / `outputSchema`
need to account for the new `$schema` key, but no schema semantics change.

Schemas using 2020-12-only keywords (e.g. `prefixItems`, nested `$schema` on subschemas)
are still rejected by the draft-04 metaschema validator, as before.
Lifting that restriction depends on adopting a 2020-12-capable validator and is out of scope
for this change.
2026-05-11 13:44:30 +09:00
Koichi ITO
3a68937bb7 Use symbolize_names: true in JSON.parse calls
Replace custom `deep_transform_keys` in `Tool::Schema` and
string key access in `StreamableHTTPTransport` with
the `symbolize_names: true` option of `JSON.parse`.

This aligns `StreamableHTTPTransport` with `StdioTransport`,
which already uses `symbolize_names: true`.
2026-03-31 04:15:42 +09:00
Koichi ITO
99cbefc610 Allow $ref in Tool::Schema for protocol version 2025-11-25
`Tool::Schema` rejected `$ref` with an `ArgumentError`.
Earlier MCP spec versions (2024-11-05 through 2025-06-18) did not define
a JSON Schema dialect or reference the `$ref` keyword. `inputSchema` was described
only as "JSON Schema defining expected parameters" with no further constraints:

- https://modelcontextprotocol.io/specification/2024-11-05/server/tools
- https://modelcontextprotocol.io/specification/2025-06-18/server/tools

The MCP spec 2025-11-25 introduced a "JSON Schema Usage" section that adopts
JSON Schema 2020-12 as the default dialect. Since `$ref` is a core keyword in 2020-12,
it is now allowed for protocol version 2025-11-25 and later.

- https://modelcontextprotocol.io/specification/2025-11-25/basic#json-schema-usage
- https://modelcontextprotocol.io/specification/2025-11-25/server/tools
- https://json-schema.org/draft/2020-12/release-notes

For backward compatibility with older protocol versions (2025-06-18 and earlier),
`Server#validate!` continues to raise `ArgumentError` when a tool input schema contains `$ref`.
This preserves the existing behavior for servers targeting those spec versions.
2026-02-27 14:33:36 +09:00
Edgars Beigarts
a3c24d2bcc
Refactor schema validation to reduce duplication and add additionalProperties support
This refactoring extracts common schema validation logic into a base Schema
class, eliminating ~100 lines of duplicate code between InputSchema and
OutputSchema. It also adds support for the JSON Schema `additionalProperties`
keyword to allow fine-grained control over extra properties in tool arguments.

Previously `additionalParameters` could be set only on OutputSchema.

Key changes:

- Created MCP::Tool::Schema base class consolidating:
  - Schema validation against JSON Schema Draft 4 metaschema
  - Deep key transformation (string to symbol conversion)
  - $ref disallowance checking
  - Common validation methods

- Refactored InputSchema to inherit from Schema:
  - Simplified constructor to accept full schema hash
  - Changed required fields storage from symbols to strings
  - Removed ~40 lines of duplicated validation logic

- Refactored OutputSchema to inherit from Schema:
  - Removed ~54 lines of duplicated validation logic
  - Now shares all common functionality with InputSchema

- Updated Tool.input_schema setter:
  - Simplified to pass full hash directly to InputSchema

- Added additionalProperties support:
  - By default, tools now allow additional properties (JSON Schema default)
  - Can explicitly set `additionalProperties: false` to disallow extras
  - Added comprehensive test coverage for both behaviors
2025-12-12 12:49:52 +02:00