Commit graph ruby-sdk/lib/mcp/tool/output_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
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
Devon Boyer
b34fae93f0 Fix symlink path comparison in schema validation 2025-12-04 22:39:23 -05:00
ramos
4c13f6dc08 Update metaschema URI format in input_schema.rb and output_schema.rb for
cross-compatibility across different OS
2025-10-03 17:29:50 -03:00
Alexey Blinov
4ee128ad67 Allow output schema to be array of objects
closes #142
2025-09-25 11:56:03 +05:00
Valentino Stoll
aad8e94530
Add Tool output_schema support with comprehensive validation
This commit implements optional JSON Schema validation for tool outputs according to the MCP specification:

- Add MCP::Tool::OutputSchema class mirroring InputSchema functionality
- Support output_schema in class definitions, Tool.define, and OutputSchema objects
- Include validate_result method for runtime validation
- Update MCP::Client::Tool to track output schemas
- Add comprehensive test coverage with 249 passing tests
- Update README with dedicated Tool Output Schemas section
- Follow MCP spec requirements for server/client validation
2025-09-11 11:12:27 -04:00