MCP 2026-07-28: JSON Schema handling (dialects, local $ref, composition bounds, any structuredContent) - #233
Open
simonx1 wants to merge 38 commits into
Open
MCP 2026-07-28: JSON Schema handling (dialects, local $ref, composition bounds, any structuredContent)#233simonx1 wants to merge 38 commits into
simonx1 wants to merge 38 commits into
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
simonx1
force-pushed
the
mcp-2026/authorization
branch
3 times, most recently
from
September 1, 2026 21:52
9b74f99 to
37df0f6
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
2 times, most recently
from
September 1, 2026 22:04
672ab57 to
9423c90
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 1, 2026 22:10
0b71f80 to
ab2ccdb
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 1, 2026 22:10
9423c90 to
6b154b7
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 1, 2026 22:20
ab2ccdb to
b5a5449
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
2 times, most recently
from
September 1, 2026 22:28
de66876 to
e59c415
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 1, 2026 22:40
ecd5555 to
9679bbf
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 1, 2026 22:40
e59c415 to
12154f1
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 1, 2026 23:01
23c084f to
af13a35
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 1, 2026 23:01
12154f1 to
85b515a
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 1, 2026 23:18
af13a35 to
00bf500
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
2 times, most recently
from
September 1, 2026 23:33
c942ac8 to
15dc647
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 1, 2026 23:46
6398827 to
3d43629
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 1, 2026 23:52
c66edee to
868c566
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
2 times, most recently
from
September 2, 2026 00:24
cae3f00 to
6fa9dc7
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
2 times, most recently
from
September 2, 2026 01:25
56b2f87 to
3f8c5a9
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 2, 2026 01:32
a42d7ec to
140ab2a
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 2, 2026 05:09
da6872c to
47c020c
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 2, 2026 07:23
fed037e to
99e90df
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 2, 2026 07:28
7ff45a0 to
e5f54ed
Compare
simonx1
force-pushed
the
mcp-2026/authorization
branch
from
September 2, 2026 09:21
93166c2 to
c2d7011
Compare
…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
force-pushed
the
mcp-2026/authorization
branch
from
September 6, 2026 17:02
4d45da6 to
347a3d6
Compare
simonx1
force-pushed
the
mcp-2026/json-schema
branch
from
September 6, 2026 17:04
9893fa4 to
ae786ae
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,structuredContentas any JSON value).What changes
$schemais 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_schemaexplains why a schema is unusable. An unusableoutputSchemais a structured-content violation (warning, orValidationErrorin:strictmode); an unusableinputSchemais logged once per tool while the call still goes out.$ref. Local references (#,#/$defs/...,#/definitions/..., any JSON pointer with~0/~1and percent escapes) are resolved, recursively, with a hop limit that turns cycles into errors. A$refto a network URI, another document,urn:orfile:is never dereferenced (no opt-in fetch mode exists) and makes the schema unusable rather than permissive; an unresolvable local$refis an error too.allOf,anyOf,oneOf,not,if/then/elseand 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.nullis a valid value validated like any other; arrays and scalars are validated against the output schema.x-mcp-headeris 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