Skip to content

MCP 2026-07-28: JSON Schema handling (dialects, local $ref, composition bounds, any structuredContent) - #233

Open
simonx1 wants to merge 38 commits into
mcp-2026/authorizationfrom
mcp-2026/json-schema
Open

MCP 2026-07-28: JSON Schema handling (dialects, local $ref, composition bounds, any structuredContent)#233
simonx1 wants to merge 38 commits into
mcp-2026/authorizationfrom
mcp-2026/json-schema

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

PR 10 of the MCP 2026-07-28 series (stacked on #232). Implements the JSON Schema rules: https://modelcontextprotocol.io/specification/2026-07-28/basic#json-schema-usage and the tools changes (any 2020-12 keywords in inputSchema / outputSchema, structuredContent as any JSON value).

What changes

  • Dialects. A schema without $schema is 2020-12; 2020-12, 2019-09 and draft-07 are accepted (SchemaValidator::SUPPORTED_DIALECTS, documented); any other declared dialect is reported as "not supported" instead of validating permissively. SchemaValidator.check_schema explains why a schema is unusable. An unusable outputSchema is a structured-content violation (warning, or ValidationError in :strict mode); an unusable inputSchema is logged once per tool while the call still goes out.
  • $ref. Local references (#, #/$defs/..., #/definitions/..., any JSON pointer with ~0/~1 and percent escapes) are resolved, recursively, with a hop limit that turns cycles into errors. A $ref to a network URI, another document, urn: or file: is never dereferenced (no opt-in fetch mode exists) and makes the schema unusable rather than permissive; an unresolvable local $ref is an error too.
  • Composition and bounds. allOf, anyOf, oneOf, not, if/then/else and boolean schemas are evaluated and no longer count as unsupported keywords. Bounds: MAX_SCHEMA_DEPTH, MAX_SUBSCHEMAS, MAX_REF_DEPTH, and the existing per-validation time budget now stops the whole walk.
  • structuredContent. Presence is decided by the key, so null is a valid value validated like any other; arrays and scalars are validated against the output schema. x-mcp-header is ignored by the validator.

Tests

spec/lib/mcp_client/json_schema_2026_spec.rb (23 examples) plus updates to the existing validator spec for the widened keyword set. Full suite green; rubocop clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 force-pushed the mcp-2026/authorization branch 3 times, most recently from 9b74f99 to 37df0f6 Compare September 1, 2026 21:52
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch 2 times, most recently from 672ab57 to 9423c90 Compare September 1, 2026 22:04
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from 0b71f80 to ab2ccdb Compare September 1, 2026 22:10
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 9423c90 to 6b154b7 Compare September 1, 2026 22:10
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from ab2ccdb to b5a5449 Compare September 1, 2026 22:20
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch 2 times, most recently from de66876 to e59c415 Compare September 1, 2026 22:28
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from ecd5555 to 9679bbf Compare September 1, 2026 22:40
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from e59c415 to 12154f1 Compare September 1, 2026 22:40
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from 23c084f to af13a35 Compare September 1, 2026 23:01
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 12154f1 to 85b515a Compare September 1, 2026 23:01
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from af13a35 to 00bf500 Compare September 1, 2026 23:18
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch 2 times, most recently from c942ac8 to 15dc647 Compare September 1, 2026 23:33
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from 6398827 to 3d43629 Compare September 1, 2026 23:46
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from c66edee to 868c566 Compare September 1, 2026 23:52
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch 2 times, most recently from cae3f00 to 6fa9dc7 Compare September 2, 2026 00:24
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch 2 times, most recently from 56b2f87 to 3f8c5a9 Compare September 2, 2026 01:25
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from a42d7ec to 140ab2a Compare September 2, 2026 01:32
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from da6872c to 47c020c Compare September 2, 2026 05:09
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from fed037e to 99e90df Compare September 2, 2026 07:23
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 7ff45a0 to e5f54ed Compare September 2, 2026 07:28
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from 93166c2 to c2d7011 Compare September 2, 2026 09:21
simonx1 and others added 28 commits September 6, 2026 19:00
…ounded wide maps

- A resource ($id URI) reached through a definition bag the dialect does
  not walk names its own anchors, while nothing outside it sees them.
- The structural bound counts object entries as well as array members,
  so a wide map of leaf values is rejected before it is copied, and the
  copy of the peer's document runs under the validation deadline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… anchor index, pointer escapes

- A branch the validator can only partly evaluate (an unsupported
  assertion such as multipleOf) is no verdict for not, oneOf or if:
  neither a match nor a mismatch, so {"not": {"multipleOf": 2}} never
  rejects a value; anyOf / allOf keep the permissive direction.
- A document nested deeper than MAX_SCHEMA_DEPTH schema levels can hold
  is rejected while it is copied instead of keeping the rest raw and
  uncharged; the client keys its once-per-schema checks by the schema
  object instead of hashing a peer document whole.
- Under draft-07 a URI $id beside a $ref is ignored with the other
  siblings and starts no resource.
- An anchor index that stopped at its bound before reaching every object
  makes the schema unusable instead of validating under guessed dialects.
- A JSON Pointer token with "~" not followed by "0" or "1" is
  unresolvable (RFC 6901).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…cation, undecided only for applicable assertions

- A $id resource the anchor index could not reach (beyond the depth
  bound) counts as truncation, and a reference from a schema the index
  does not know is unresolvable instead of resolving against the document
  root.
- A not / oneOf / if branch is undecided only while an unevaluated
  assertion that applies to the instance remains: annotations (format,
  contentSchema) and assertions of another instance type decide nothing,
  so a branch settled by its evaluated keywords is a full verdict.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…h uncertainty

- A branch that fails on an evaluated assertion is :fail and leaves no
  uncertainty behind, so not(not(...)) with an unevaluated sibling still
  rejects a value the evaluated keyword rejected.
- anyOf passes once any branch definitely passes, whatever the order of
  an undecided one; only branches that are all undecided leave it open.
- oneOf fails once two branches definitely pass, regardless of undecided
  ones; uncertainty matters only with zero or one definite pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…effective assertions are uncertainty, lexical bounds

- anyOf / oneOf / allOf stop evaluating branches once the outcome is
  definite (any pass, two passes, the first failure), restoring the
  uncertainty count, so a later branch cannot abort a decided validation.
- An unevaluated assertion is uncertainty under not / oneOf / if only when
  it can still change the result: minContains / maxContains need
  contains, additionalItems a tuple items, and tautological values
  (additionalProperties true, uniqueItems false, minProperties 0, empty
  dependency maps) decide nothing.
- A referenced target is bounded by its own lexical depth rather than the
  referring location, a boolean target is not charged per reference, and
  boolean subschemas obey the depth bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…e input schemas skip assertions, tautological additionalItems

- Lexical depths follow each resource's own dialect and the keyword scan
  reads a referenced target at its lexical depth, so neither key order
  nor reference fan-out changes a bounds or coverage verdict.
- A boolean a pointer reaches obeys MAX_SCHEMA_DEPTH at its own position.
- An input schema the validator cannot interpret asserts nothing: the
  call goes out without the required check.
- additionalItems true / {} beside a tuple items is decided, not
  uncertainty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ata keywords bounded, instance-aware assertions

- The depth of what a pointer $ref reaches is counted in schema steps
  along the percent-decoded pointer, so a boolean legal at the bound is
  not rejected when referenced through a map or array keyword.
- Every token under a data or unknown keyword is a step, so a document
  hidden inside default / enum / const / examples or a vendor keyword
  obeys MAX_SCHEMA_DEPTH too, and an object reached that way is walked
  at its own position.
- An unevaluated assertion is uncertainty only when it can still change
  this instance: additionalItems false decides nothing when the tuple
  covers every item, contains decides nothing when minContains is 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…teps, boolean targets charged, draft-07 format undecided

- A pointer step through a keyword the dialect in force does not define
  is opaque data, so a target's depth cannot be undercounted.
- Every distinct boolean a local $ref reaches is charged once toward
  MAX_SUBSCHEMAS.
- Under draft-07 format is an unevaluated assertion (a string branch
  carrying one is undecided); 2019-09 and 2020-12 keep it an annotation.
- A companion keyword the dialect does not define (minContains under
  draft-07) changes nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…t in force, ineffective assertions decide nothing

- The depth of what a pointer $ref reaches is classified by the dialect
  in force at each node (an embedded resource's own $schema takes over
  when the pointer enters it), and an opaque count is never overridden by
  a lexical depth recorded under another grammar.
- A boolean the preflight walk already admitted is not charged again when
  a reference reaches it.
- A schema a pointer reaches under a data or vendor keyword is indexed on
  arrival and normalized when given with symbol keys.
- Under not / oneOf / if, an unevaluated assertion that cannot change
  this instance (unevaluatedItems over a covered array, additional or
  unevaluated properties over covered objects, uniqueItems below two
  items, bounds the instance cannot cross, dependencies without their
  triggers) decides nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…s cache copies

Tool#schema_identity names a tool definition: the copies the client cache
hands out carry the same token and a refreshed definition a new one, so
the unusable-input-schema and partial-coverage checks run once per
definition across cache hits and again after a refresh, without hashing
a peer-supplied document. MCPClient::DeepCopy.copy is iterative, so a
peer document nested deeper than the Ruby stack cannot overflow it while
being copied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…hing, unvisited boolean positions charged

- Under not / oneOf / if, contains true / {} on an array that already
  holds the items minContains requires, unevaluatedItems once such a
  contains (or an items schema) evaluated every item, patternProperties
  matching nothing or only tautologies, and a dependency whose present
  trigger cannot fail decide nothing instead of leaving the branch
  undecided.
- A boolean in a position the preflight walk never visits (beside a
  draft-07 $ref, or behind an opaque keyword) is charged toward
  MAX_SUBSCHEMAS when a reference reaches it.
- The unsupported-keyword scan follows a pointer into a data keyword at
  the target's own position, so a referrer at the depth bound cannot hide
  a reported keyword.
- A symbol-keyed target adopted from a data keyword is copied within the
  structural budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…d and indexed adopted targets, symbol triggers, explicit null output schemas

The pattern matching behind the patternProperties / additionalProperties /
unevaluatedProperties coverage checks now runs under the validation
deadline and the regexp timeout validate_pattern uses, so a backtracking
server-supplied expression cannot hold the calling thread. Every schema a
pointer adopts from a data or vendor keyword is charged against
MAX_STRUCTURAL_OBJECTS (string keys included, which is what arrives over
the wire) and its reachable positions are indexed within the existing
bounds, so a nested $ref such as #/default/$defs/i resolves and nested
schemas keep the adopted resource's dialect. Dependency triggers are
looked up in both key forms. An explicit "outputSchema": null is a
declaration, not an absent field: structuredContent is still required and
the null schema is reported as unusable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ata-keyword anchors never named

A subtree a pointer enters through a data keyword is normalized and
indexed once, memoized by the identity of the object the document holds,
and the remaining pointer tokens are walked through that copy: a nested
pointer such as `#/default/$defs/i` lands on the object the index already
knows, with the resource, dialect and subschema charge it gave it,
instead of a second copy attributed to the referrer's resource at depth 0
under the referrer's dialect. An adopted target declares no names of its
own unless an `$id` really starts a resource there, so an `$anchor`
inside a data keyword neither collides with the document's anchors nor
makes `#name` resolvable depending on member order, and `validate` reuses
the index the preflight built rather than building a second one.

Also from the round-21 reviews: a `$ref` fragment whose percent-escapes
do not decode to valid UTF-8 resolves to nothing instead of raising out
of the validation; a property, definition or pattern named `enum`,
`const`, `default` or `examples` is a schema position like any other (its
subschemas are normalized and its references checked); a composition
branch its supported assertions already rejected is not measured for the
keywords the validator does not evaluate, so a pathological
`patternProperties` pattern cannot abort a settled branch; and the
once-per-definition input-schema and coverage checks are forgotten with
the tool-cache entries they name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…mber resolves

The JSON pointer "/" (written "#/" or "#%2F") is the member named "" (RFC
6901 Section 5), not the whole document: tokenizing it with
`fragment[1..].split('/', -1)` dropped the token entirely, since "" splits
to no tokens at all, so the walker landed back on the resource root and the
chain was reported as cyclic. A legal schema whose target is the empty-named
member was unusable, and in :strict every structuredContent was a violation.
The leading separator is now dropped rather than split off, in both
`resolve_adopted_pointer` and `pointer_tokens`, so "#/" reaches the member
and is unresolvable when there is none.

A `contains` whose schema matches every item (true / {}) asserts on a count
that is the array's own length, so it is now decided outright — the default
minContains of 1 rejects the empty array and maxContains bounds it — instead
of only marking a branch undecided, which let not / if / oneOf / anyOf fail
open. A `dependentRequired` (or draft-07 `dependencies`) list whose names the
instance already carries cannot fail, so those branches are decided too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… reference unresolvable

A `$ref` fragment whose percent escapes are malformed (`#/$defs/a%ZZ`,
`#/$defs/a%`) decodes to nothing, so the reference is unresolvable
(RFC 3986 Section 2.1) rather than falling back to the undecoded text and
resolving onto a member literally spelled that way.

The number of items a `contains` can match lies between none and the
array's length whatever its item schema says, so the bounds that range
settles are decided: any `contains` rejects an array shorter than
`minContains` (the empty array by default), and a `contains` of `false`
matches nothing. Composition no longer fails open on them.

The validation-time `$ref` hop budget counts the references applied to one
instance value and starts over below each property and item, so a
recursive schema describes data nested deeper than 32 levels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…nsatisfiable contains bounds, a depth-bounded walk

The keyword scan gave a `$ref` target that is not a schema object a
lexical depth of `false`, which the opaque-pointer branch then compared
against an Integer (`ArgumentError`). Such a target has no lexical depth
at all: it is nil now, so the scan runs to the end and an accepted schema
can no longer turn a successful tool call into an exception, even in
:warn mode.

`contains` bounds that no count can satisfy — `minContains` above
`maxContains`, the default `minContains` of 1 beside `maxContains: 0` —
are settled by the bounds alone, before any appeal to an item schema the
validator cannot decide, so `anyOf` / `if` / `oneOf` / `not` no longer
fail open on them. Bounds the array's length genuinely cannot settle
still leave the keyword unevaluated, and a tautological non-literal
`contains` still reads as bounded by the array's length. Round 24's
example for the `maxContains: 0` case encoded the opposite contract; it
asserted a pass for a schema nothing satisfies, so it now switches the
lower bound off, which is the case that really is undecidable.

The walk over an instance was plain recursion with no depth bound:
`MAX_NODE_VISITS` sits far above what MRI's stack allows, so a deeply
nested value validated against a recursive schema raised SystemStackError
past every bound the validator applies — a process-level crash rather
than the documented abort with one error. The nesting is counted now and
bounded at `MAX_NODE_DEPTH`, well above what a peer can send (`JSON.parse`
refuses more than 100 levels) and well below what the smallest stack this
library runs on can carry; the public `SchemaValidator.validate` entry
point, which is not gated by `JSON.parse`, aborts with one validation
error instead. The string and number keywords move to a Scalars mixin to
keep the module within its length bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…into the instance

The walk's depth budget (round 25) counted every frame of the walk: `$ref`
hops and allOf/anyOf/oneOf/if branches applied to the *same* instance value
as well as steps into a child. A recursive schema composed through a handful
of `$defs` mixins spends several such frames per instance level, so the
count ran up at a multiple of the data's depth and JSON a peer can
legitimately send — nesting under `JSON.parse`'s default max_nesting of 100
— aborted with "instance or schema nesting deeper than 512": a
SchemaValidationError under :strict, a false mismatch under :warn, for an
instance the MCP tools spec says a client SHOULD validate.

Only a step into a child value — an array item or a property value — is
counted now, in a new `validate_child` beside the point where the
validate-time `$ref` hop budget already starts over. Same-instance recursion
stays bounded by MAX_REF_DEPTH and MAX_NODE_VISITS as before.

The bound is 256 levels of instance nesting, which keeps round 25's
effective budget for a plain recursive schema and stays below what a
transport reader thread's stack carries. A depth bound cannot on its own
promise the stack holds, since it does not bound what one level costs, so
`validate` also catches SystemStackError and reports it as one aborted
validation: an instance nested past what the stack can carry still ends as a
validation error rather than an exception escaping a tool call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…d iteratively

Round 26 stopped counting the schemas a node applies to the same instance
value against MAX_NODE_DEPTH, but each of them still cost a Ruby stack
frame: a recursive schema composed through N `$defs` mixins applies N of
them per instance level, so the stack grew with the product of the
instance depth and the mixin count. Eight `allOf` mixins aborted a
90-level instance with "schema too deeply recursive for this stack" on
the main thread, and four aborted a 99-level one on a Thread -- the stack
a transport's reader runs on -- so a conforming structuredContent failed
the validation the MCP tools spec says a client SHOULD perform.

The same-instance applications -- a `$ref` hop, an allOf/anyOf/oneOf/not/
if branch, a then/else -- now run on an explicit stack in the new
SchemaValidator::Evaluation module rather than on the interpreter's: a
step is requested by returning [:apply, ...] and finished by returning
[:done, errors], and validate_node drives the two. A Ruby frame is spent
only on the step into a child value, which is what MAX_NODE_DEPTH counts,
so every mixin count now reaches the full 256 levels the bound names on
either stack (measured stack capacity: 837 levels on the main thread and
404 on a Thread, both independent of the mixin count, against 89 and 44
for eight allOf mixins before). An instance nested past the bound aborts
as "instance nested deeper than 256" rather than on the stack; the
SystemStackError rescue stays as a backstop.

Also: a draft-07 `$id` is a URI reference, so the plain name it declares
is percent-decoded (RFC 3986 Section 2.1) exactly as a `$ref`'s fragment
already was. `$id: "#foo%2Dbar"` declares the anchor "foo-bar", so the
matching `$ref: "#foo%2Dbar"` resolves instead of being reported
unresolvable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ll stack

Seven findings from the verification pass over PR 10.

The preflight (`check_schema`) and the coverage scan (`unsupported_keywords`)
still spent an interpreter frame per `$ref` hop. Both run *before* `validate`,
so its `SystemStackError` rescue could not cover them: a shallow document below
every structural bound whose references chain through 400 schemas overflowed
the stack of the thread a transport reads on, through `Client#call_tool` as its
input or its output schema. Both now read the document from an explicit stack,
in the same depth-first document order, so only the resource bounds decide.

An `inputSchema` declaring a dialect this client does not implement had its
parameter check silently skipped and the call sent anyway, in `:strict` mode as
much as in `:warn`. MCP 2026-07-28 requires an error the caller can see, so
`call_tool` raises `ValidationError` naming the dialect and does not send;
SEP-2106 assigns no JSON-RPC code, so it is a library error. Every other reason
a schema is unusable is still only warned about.

A `$ref` naming a resource the document bundles is resolved inside it:
references resolve as URI references (RFC 3986 Section 5.2) against the base
their resource declares, so an absolute `$id`, a relative URI and the empty
reference all reach the embedded resource, and only a reference to one the
document does not carry is external. `definitions` is the deprecated `$defs`
of 2020-12 and 2019-09, whose meta-schemas retain it; `$defs` stays unknown to
draft-07. 2019-09 and draft-07 anchor names admit a colon, which the 2020-12
syntax applied to every dialect had rejected.

Empty `allOf` / `anyOf` / `oneOf` / `prefixItems`, non-schema definition-bag
entries and malformed assertion keyword values now make a schema unusable
instead of reading as permissive. A condition this validator cannot decide
still reports a failure both `then` and `else` agree on, since exactly one of
them is applied whichever way the condition goes.

Coverage: the normalization-deadline example now proves the copy stops before
reading the rest of the document (it had survived removing the check), and the
tautological `additionalItems` examples carry an additional item, so the
keyword they name applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… evaluated

MCP 2026-07-28 basic "Implementation Requirements" makes JSON Schema 2020-12
support mandatory, and a standard assertion left unevaluated is not a smaller
report but a wrong verdict: it makes a composition branch undecided, and an
undecided branch is accepted wherever the composition is monotonic.

- Every decidable standard keyword is applied now (multipleOf, uniqueItems,
  contains item by item with minContains/maxContains, minProperties /
  maxProperties, patternProperties, additionalProperties, propertyNames,
  dependentRequired / dependentSchemas and both halves of draft-07
  dependencies, additionalItems), so `{"allOf": [{"multipleOf": 3}]}` no
  longer admits 4, `{"not": {"multipleOf": 2}}` no longer admits 4,
  `uniqueItems` no longer admits [1, 1] and `contains` is no longer decided
  by the array's length alone. Only what needs composition-wide annotations
  (unevaluatedItems / unevaluatedProperties), a dynamic scope ($dynamicRef /
  $recursiveRef) or that only annotates (format, contentSchema) is left to
  the partial-coverage report.
- A `pattern` is matched with ECMAScript's anchor semantics, so "a\nb" no
  longer satisfies "^a$" (and is no longer rejected by a `not` around it).
- An unsupported output dialect is an error in both modes, and the dialect
  of the definition a call was actually answered under (a HeaderMismatch
  retry's refreshed inputSchema) is refused too.
- A present null structuredContent is structured content only from
  2026-07-28 on; a session negotiated to 2025-11-25 reads it as missing.
- Two schema resources may not answer to one URI, and the preflight reads
  the remaining keyword shapes (duplicate `required` names, a modern `$id`
  with a non-empty fragment, a malformed anchor, negative contains and
  property bounds, a non-positive multipleOf, a non-string dynamic ref).
- A reference is retargeted to its bundled resource before its pointer is
  read, so `urn:root#/x/y` is charged toward the subschema bound exactly as
  `#/x/y` is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…terns, unevaluated*

Round 28 started applying the standard assertions; two independent reviews
found the verdicts some of them reach are wrong, and that the checks run in
places the resource bounds do not reach.

- `uniqueItems` compares JSON values. An object was canonicalized into an
  array of its member pairs with nothing recording its type, so `[{}, []]`
  and `[{"a": 1}, [["a", 1]]]` read as duplicates: :strict rejected a
  conforming result, and through `not` accepted one the schema rejects.
- A `pattern` is read as ECMA-262, not merely anchored like one. `\A`
  anchors in Ruby and is a literal "A" there (Annex B identity escape), `.`
  excludes four line terminators rather than one, `\s` counts the Unicode
  spaces Ruby's does not, and `[]` — an empty class matching nothing — is no
  expression at all in Ruby, so the keyword was skipped and every string
  passed it. Patterns are translated before they are matched, character
  classes included (Ruby's nested classes and `&&` intersection are literals
  in ECMA-262).
- `unevaluatedItems` / `unevaluatedProperties` are evaluated at a node that
  produces every annotation they read: with no in-place applicator beside
  them (and, for items, no `contains`) they are `additionalItems` /
  `additionalProperties` over what the node did not name. Where a
  composition produces the annotations they stay unevaluated and reported,
  as the dynamic references always do — a dynamic scope is not something a
  node can produce for itself.
- The loops over the instance consult the budget. How wide an object or an
  array is, is the peer's choice, and a member decided without descending
  into it visits no node, so a wide enough instance ran past the deadline —
  and, inside a speculative branch, reported the work as a pass.
- The metadata keywords must be written as their type. `{"readOnly": "no"}`,
  `{"title": 5}` and their kind preflighted clean, so :strict checked results
  against a schema no validator could read.
- An ordinary streamed result is validated. `call_tool_streaming` handed the
  transport's chunks back unchecked unless the tasks extension was on and the
  chunk was a task, so the payload `call_tool` refuses came through it
  silently — the unsupported output dialect included.
- A HeaderMismatch retry is not sent under a schema nothing can read. The
  rejection means the server did not execute the attempt and the retry is the
  send that would, so a refreshed inputSchema declaring an unsupported
  dialect now stops the call before it goes out.
- 2025-11-25 structured content is an object: an array, a string, a number or
  a boolean is no more structured content there than a null is.

Coverage: the two task-result methods, the output-dialect memo's eviction,
the client's use of an output schema's own dialect grammar, an embedded
output dialect, the resolver's URI forms and pointer failures. The examples
that used `unevaluatedItems` as a stand-in for "a keyword nothing can decide"
now use `$dynamicRef`, which really is one, and the pattern fixtures written
in Ruby's anchor spelling no longer record the wrong dialect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
This branch checks the refreshed definition's dialect before a
HeaderMismatch retry goes out, resolving the tool through the transport.
The example below it that pins the recovery guard sends nothing and has
no connection to resolve through, so that step is stubbed there; the
dialect check keeps its own examples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ed keywords

Fourth verification round of the JSON Schema 2020-12 work (codex + grok):

- The ECMA-262 pattern translation walked the pattern by index (quadratic
  on multibyte text) without consulting the deadline, so a 100k-character
  pattern held the thread for seconds past the 1s budget and, under `not`,
  passed. It is now a linear pass over the characters that checks the
  deadline as it goes, and a pattern or patternProperties key longer than
  MAX_PATTERN_LENGTH (10,000) makes the schema unusable at preflight.
- The rest of ECMA-262: a back-reference to a non-participating group
  matches the empty string (written as a Ruby conditional); a numeric
  escape past the group count is the legacy octal escape; a surrogate pair
  is the character it encodes and a lone surrogate matches nothing; Ruby-
  only syntax (inline flags, possessive quantifiers, atomic groups,
  comments, (?'n') groups) is refused rather than read as Ruby; an
  unreadable pattern is a malformed keyword, never an absent one.
- Malformed core keywords are refused at preflight: an $id that is no URI
  reference, a $vocabulary that is not an object of booleans keyed by URI,
  a 2019-09 $recursiveAnchor that is not a boolean.
- :strict refuses a result against a schema using an assertion this
  validator does not evaluate ($dynamicRef/$recursiveRef, unevaluated*
  beside a composition) with a ValidationError on every call; annotations
  still pass and :warn keeps its once-per-definition warning.
- The HeaderMismatch retry goes out under the very definition its dialect
  check read (pinned for the send), instead of a fresh lookup that on a
  ttlMs: 0 list fetched another, unchecked definition.
- validate_params! follows the root's $ref chain and allOf members for
  `required` (and their `properties` defaults), per the tools spec SHOULD.
- Coverage: loop checkpoints that visit no node, separately allocated deep
  const/enum values, concurrent validations, and the strict/streaming/task
  paths for the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…s, and bring the checks under the rules

JSON Schema handling, fifth verification round.

unevaluatedProperties / unevaluatedItems are evaluated wherever they
appear: every application of a schema to an object or array keeps what it
evaluated of the value (Evaluated), filled in by the node's own keywords
and merged from every in-place applicator whose subschema passed ($ref,
allOf, anyOf, oneOf, if/then/else, dependentSchemas) - never from a failed
branch, never from a cousin - and the keyword is applied to what is left
once the composition has run. anyOf evaluates every branch where an
unevaluated keyword upstream reads the annotations, and stops at the first
pass otherwise. The :strict refusal of such schemas, which rejected
conforming results, is gone; the closed composition SEP-2106 made legal on
a tool schema gets its 2020-12 verdict on the synchronous, streaming and
task paths.

A $dynamicRef naming a pointer or a plain $anchor, and a $recursiveRef
whose target carries no $recursiveAnchor: true, are the plain references
the specification says they are: applied as a $ref (after it), checked as
one at preflight, and no longer reported. Only a reference the dynamic
scope could re-bind stays unevaluated, reported and refused in :strict.

ECMA-262 word boundaries are defined over [A-Za-z0-9_] rather than Ruby's
Unicode word characters; named and unnamed groups are numbered alike, so a
numeric back-reference beside a named group is usable. The preflight runs
under the validation-wide deadline (before every position and pattern),
and check_schema gets a budget of its own. A $schema off a resource root
and a $vocabulary keyed by a relative reference are refused. Input
requirements are read under each embedded resource's own dialect. An
output dialect this client cannot read stops the call before it is sent
and refuses error results too. A streamed chunk without resultType that is
shaped as no CallToolResult is progress, not an answer to validate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…s annotations

Sixth verification round, the correctness findings:

- A $dynamicRef whose initial target declares the anchor binds to the
  outermost dynamic scope that declares it (JSON Schema 2020-12 Core
  Section 8.2.3.2). The dynamic scope always starts at the root resource
  of the schema being applied, so where the root declares the anchor the
  binding is the root's wherever the reference is met (the recursive-node
  and strict-tree shapes), and where exactly one resource declares it the
  binding is the target the reference named; a $recursiveRef to a root
  whose $recursiveAnchor is true likewise (2019-09 Section 8.2.4.2.2).
  Only several non-root resources declaring one dynamic anchor would need
  the evaluation path to choose: that reference stays unevaluated,
  reported, and refused in :strict. The previous round refused every
  dynamic-anchor reference in :strict and let an invalid instance through
  in :warn.
- An if without then or else is evaluated all the same: the annotations
  of a condition that passed are what an unevaluated keyword beside it
  reads (Section 10.2.2.1).
- The name generated for an unnamed capture group uses a prefix none of
  the pattern's own group names begins with, so a written (?<__mcp_g1>
  cannot be the group a numeric back-reference is rewritten to name.
- The HeaderMismatch retry checks the refreshed definition's output
  dialect as well as its input dialect before it goes out.
- The structuredContent an error result carries is validated; only its
  absence is exempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The deferral pins move to the one case still deferred (a dynamic anchor
several non-root resources declare); a root-anchored $recursiveRef and a
branchless if are pinned as evaluated, a self-applying one as the cycle
it is reported as. New examples: the HeaderMismatch retry leaves no
pinned definition behind once refused, and two overlapping retries of
one tool each go out under the definition their own refresh read;
validations overlapping with opposite verdicts, including resource-local
$refs resolving differently; a session negotiated to 2025-11-25 on the
streaming path; the public task API refusing an unreadable output
dialect before anything is sent, refusing a delivered result a closed
composition rejects, and validating a delivered result against the
definition the task was created under; and the deadline example now
proves compilation started before it stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The cache-slice methods moved into their own mixin on the caching branch,
so the memo cleanup this branch adds sits directly against the neighbouring
definition; give it the blank line the style requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…t be answered

The dynamic scope — the schema resources an evaluation entered on its way to
a reference — is now tracked as the validation enters and leaves them, and a
$dynamicRef binds to the outermost resource of that scope declaring its
anchor (JSON Schema 2020-12 Core Section 8.2.3.2), a $recursiveRef likewise
(2019-09 Section 8.2.4.2.2). A resource the instance never enters is not in
the scope and declares nothing for the reference, so a document holding a
second, unvisited declaration of an anchor no longer leaves the reference
unevaluated and the instance wrongly accepted. Nothing about the dynamic
references is deferred any more: they are gone from the partial-coverage
report, and :strict gates on the verdict rather than refusing the schema.

Also from the seventh review round: `contains` supplies the item annotation
unevaluatedItems reads in 2020-12 and not in 2019-09, whose unevaluatedItems
reads items/additionalItems alone; a back-reference to a group a quantifier
repeats and a variable-length lookbehind are ECMA-262 expressions Ruby's
engine cannot reproduce, and are now refused as such instead of answered
under the wrong rules in both directions; the definition header extraction
reads before the initial send is held to the same dialect guard the retry
already was, so a tool whose refreshed schema declares an unreadable dialect
is refused instead of running first; and an error result carrying a
non-object structuredContent on a 2025-11-25 session stays an error result
rather than being reported as a successful one missing its output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
@simonx1
simonx1 force-pushed the mcp-2026/authorization branch from 4d45da6 to 347a3d6 Compare September 6, 2026 17:02
@simonx1
simonx1 force-pushed the mcp-2026/json-schema branch from 9893fa4 to ae786ae Compare September 6, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant