fix(validators): honor declared draft-07/06 JSON Schema dialects instead of rejecting them (#2534)

This commit is contained in:
Felix Weinberger 2026-07-27 16:11:06 +01:00 • committed by GitHub
commit f130e1ac5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 851 additions and 109 deletions

View file

@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/client': patch
---
The default validator now honors declared 2019-09 and draft-07/06 dialects instead of rejecting them: a schema stamped `"$schema": "http://json-schema.org/draft-07/schema#"` (zod-to-json-schema's default output) validates with draft-07 semantics, and a 2019-09 stamp (zod-to-json-schema's `2019-09`/`openAi` targets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no `$schema` still validate as 2020-12, and unknown dialects still produce the typed error (now listing the supported dialects: 2020-12, 2019-09, draft-07, draft-06).

View file

@ -707,10 +707,9 @@ the host side and register the result with `fromJsonSchema()`: zod-4 input via z
own `z.toJSONSchema(z.object(shape), { io: 'input', target: 'draft-2020-12' })` (the
conversion is runtime-structural, so a zod ≥4.2 in the host handles schemas built by a
different zod-4 copy), zod-3 input via the
[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Strip
the `$schema` member from the converted output before passing it to `fromJsonSchema()`
— `zod-to-json-schema` stamps a draft-07 `$schema` by default, and the default
validator [accepts 2020-12 only](#json-schema-2020-12-posture-sep-1613-sep-2106).
[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Its
default draft-07 `$schema` stamp is fine as-is — the default validator
[honors declared draft-07/06 dialects](#json-schema-2020-12-posture-sep-1613-sep-2106).
How a too-old zod surfaces depends on which entry point your code imports. With
main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly
@ -1455,9 +1454,19 @@ classes — import it from one package consistently within a process.
#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106)
The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020`
instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas
declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`.
The default validator dispatches on the schema's declared `$schema`: absent or 2020-12
validates as **JSON Schema 2020-12** — on Node via `Ajv2020` instead of v1's draft-07
`Ajv` (the Cloudflare Workers default was already 2020-12) — a declared 2019-09
`$schema` validates with 2019-09 semantics (`Ajv2019`), and a declared draft-07 or
draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other
`$schema` are rejected with `Error("…unsupported dialect…")`. Two known draft-07 engine
differences: the Node engine (classic Ajv, same as v1's default) evaluates keywords
adjacent to `$ref`, stricter than draft-07's ignore-siblings rule, while the
browser/Workers engine ignores them per spec; and the browser/Workers engine does not
resolve a `$ref` inside a `dependencies` entry whose key collides with a JSON Schema
keyword (`type`, `default`, `format`, …) — validation throws `Unresolved $ref` when
that dependency triggers (surfaced as the SDK's typed validation error), while the
Node engine handles the same schema correctly.
`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to
`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is
@ -1465,13 +1474,13 @@ declaring a different `$schema` are rejected with `Error("…unsupported dialect
`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile,
surfaced per-tool on `callTool`).
| v1 pattern | Mechanical fix |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) |
| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
| v1 pattern | Mechanical fix |
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
| draft-07 idioms **without** a declared `$schema` (a declared draft-07/06 `$schema` dispatches automatically) | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) |
| undeclared draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`,
etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward

View file

@ -3,21 +3,13 @@
*/
import { Ajv as Draft7Ajv } from 'ajv';
import { Ajv2019 } from 'ajv/dist/2019.js';
import { Ajv2020 } from 'ajv/dist/2020.js';
import _addFormats from 'ajv-formats';
import { declaredDialect } from './dialects';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
/**
* Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema
* declares anything else, the default provider throws a plain `Error` with a clear message rather
* than letting the engine crash on an opaque internal error or silently mis-validate.
*/
const DRAFT_2020_12_URIS: ReadonlySet<string> = new Set([
'https://json-schema.org/draft/2020-12/schema',
'http://json-schema.org/draft/2020-12/schema'
]);
/** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */
interface AjvLike {
compile: (schema: unknown) => AjvValidateFunction;
@ -35,8 +27,8 @@ interface AjvValidateFunction {
/** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */
const addFormats = _addFormats as unknown as typeof _addFormats.default;
function createDefaultAjvInstance(): AjvLike {
const ajv = new Ajv2020({
function createDefaultAjvInstance(engineClass: typeof Ajv2020 | typeof Ajv2019 | typeof Draft7Ajv): AjvLike {
const ajv = new engineClass({
strict: false,
validateFormats: true,
validateSchema: false,
@ -50,8 +42,13 @@ function createDefaultAjvInstance(): AjvLike {
* AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv`
* for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy).
*
* Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different
* `$schema` are rejected with a plain `Error`; pass a pre-configured Ajv instance to validate
* Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `Ajv2020`
* (SEP-1613); 2019-09 → `Ajv2019`; draft-07 or draft-06 → the classic draft-07 `Ajv` class
* (draft-07's changes over draft-06 are additive, so one engine covers both). Known draft-07 deviation: classic Ajv
* evaluates keywords adjacent to `$ref` (stricter than draft-07's ignore-siblings rule, matching
* v1's default engine), while the cfworker provider ignores them per spec.
* Schemas declaring any other `$schema` are
* rejected with a plain `Error`; pass a pre-configured Ajv instance to validate
* other dialects. The SDK bundles ajv internally but does not re-export `Ajv2020` (its type
* graph tips downstream declaration bundling — see #2339). To construct a custom 2020-12
* instance, add `ajv` to your own dependencies (matching the SDK's pinned version) and
@ -80,15 +77,20 @@ function createDefaultAjvInstance(): AjvLike {
*/
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
private _ajv: AjvLike | undefined;
/** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */
/** Lazy classic (draft-07) engine, built on the first draft-07/draft-06-declared schema. */
private _ajvDraft7: AjvLike | undefined;
/** Lazy 2019-09 engine, built on the first 2019-09-declared schema. */
private _ajv2019: AjvLike | undefined;
/** True iff the constructor received a caller-supplied engine; the `$schema` dispatch is skipped. */
private readonly _userAjv: boolean;
/**
* @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is
* used for **every** schema regardless of its declared `$schema` (the caller owns dialect
* choice). When omitted, the provider constructs a single `Ajv2020` instance with
* choice). When omitted, the provider constructs per-dialect engines (`Ajv2020`, `Ajv2019`,
* and the classic draft-07 `Ajv` for draft-07/06-declared schemas) with
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call needing each**, so
* constructing the provider (e.g. as the default validator of a `Client`/`Server` that never
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter
* is typed structurally so consumers who don't pass an instance need not have `ajv` installed.
@ -98,29 +100,36 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
this._ajv = ajv;
}
/** The underlying engine — the default instance is created on first use. */
/** The underlying 2020-12 engine — the default instance is created on first use. */
private get ajv(): AjvLike {
return (this._ajv ??= createDefaultAjvInstance());
return (this._ajv ??= createDefaultAjvInstance(Ajv2020));
}
/**
* Pick the engine for a schema's declared dialect. A caller-supplied engine is used for
* every schema — do not second-guess by `$schema` (bring-your-own-validator means
* bring-your-own-dialect). Otherwise: no `$schema` or 2020-12 → `Ajv2020`; 2019-09 →
* `Ajv2019`; draft-07 or draft-06 → classic `Ajv`; anything else → `Error`.
*/
private _engineFor(schema: JsonSchemaType): AjvLike {
if (this._userAjv) {
return this.ajv;
}
const dialect = declaredDialect(
schema,
'pass a pre-configured Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.'
);
if (dialect === '2020-12') {
return this.ajv;
}
if (dialect === '2019-09') {
return (this._ajv2019 ??= createDefaultAjvInstance(Ajv2019));
}
return (this._ajvDraft7 ??= createDefaultAjvInstance(Draft7Ajv));
}
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
// Caller supplied a specific engine — do not second-guess by `$schema`
// (bring-your-own-validator means bring-your-own-dialect).
if (
!this._userAjv &&
'$schema' in schema &&
typeof schema.$schema === 'string' &&
!DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, ''))
) {
const declared = schema.$schema.slice(0, 200);
throw new Error(
`JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` +
`The default validator supports JSON Schema 2020-12 only; pass a pre-configured ` +
`Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.`
);
}
const engine = this.ajv;
const engine = this._engineFor(schema);
const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (engine.getSchema(schema.$id) ?? engine.compile(schema))

View file

@ -10,6 +10,7 @@
import { Validator } from '@cfworker/json-schema';
import { declaredDialect } from './dialects';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
/**
@ -17,23 +18,23 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSche
*/
export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12';
/**
* Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema
* declares anything else and no `{draft}` is forced, the provider throws a plain `Error`.
*/
const DRAFT_2020_12_URIS: ReadonlySet<string> = new Set([
'https://json-schema.org/draft/2020-12/schema',
'http://json-schema.org/draft/2020-12/schema'
]);
/**
* `@cfworker/json-schema`-backed JSON Schema validator. See
* `@modelcontextprotocol/{client,server}/validators/cf-worker` for the customisation entry point.
*
* Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different
* `$schema` are rejected with a plain `Error`. Passing an explicit `draft` to the constructor
* Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `'2020-12'`
* (SEP-1613); 2019-09 → `'2019-09'`; draft-07 or draft-06 → `'7'`. Schemas declaring any other `$schema` are rejected
* with a plain `Error`. Passing an explicit `draft` to the constructor
* overrides this — that draft is used for every schema regardless of `$schema`.
*
* Known draft-07 engine gap: `@cfworker/json-schema` does not treat draft-07 `dependencies` as
* a name→subschema map during `$ref` collection, so a dependency entry whose KEY collides with
* a JSON Schema keyword (`type`, `default`, `format`, `required`, `pattern`, …) and whose
* subschema contains a `$ref` throws `Unresolved $ref` at validation time when the dependency
* triggers — data-dependently, not at compile. The Node (classic Ajv) engine handles the same
* schema correctly. Callers that hit this receive the SDK's typed validation error (the throw
* is captured by the validation paths), and the deviation is pinned by a recorded-contract test.
*
* @example Use with default configuration (2020-12, shortcircuit on)
* ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default"
* const validator = new CfWorkerJsonSchemaValidator();
@ -58,14 +59,24 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
* @param options - Configuration options
* @param options.shortcircuit - If `true`, stop validation after first error (default: `true`)
* @param options.draft - JSON Schema draft version to force for every schema. When set, the
* `$schema` check is skipped. When omitted, the provider validates as 2020-12 and rejects
* schemas declaring a different `$schema`.
* `$schema` dispatch is skipped. When omitted, the provider dispatches on each schema's
* declared `$schema` (2020-12, 2019-09, draft-07, draft-06; absent means 2020-12) and rejects others.
*/
constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) {
this.shortcircuit = options?.shortcircuit ?? true;
this.draft = options?.draft;
}
/**
* Pick the engine draft for a schema's declared dialect (a caller-forced `{draft}` bypasses
* this — do not second-guess by `$schema`). No `$schema` or 2020-12 → `'2020-12'`; 2019-09 →
* `'2019-09'`; draft-07 or draft-06 → `'7'`; anything else → `Error`.
*/
private _draftFor(schema: JsonSchemaType): CfWorkerSchemaDraft {
const dialect = declaredDialect(schema, 'pass an explicit { draft } to CfWorkerJsonSchemaValidator to validate other dialects.');
return dialect === 'draft-7' ? '7' : dialect;
}
/**
* Create a validator for the given JSON Schema
*
@ -75,22 +86,7 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
* @returns A validator function that validates input data
*/
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
// Caller forced a draft — use it for everything; do not second-guess by `$schema`.
if (
this.draft === undefined &&
'$schema' in schema &&
typeof schema.$schema === 'string' &&
!DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, ''))
) {
const declared = schema.$schema.slice(0, 200);
throw new Error(
`JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` +
`The default validator supports JSON Schema 2020-12 only; pass an explicit ` +
`{ draft } to CfWorkerJsonSchemaValidator to validate other dialects.`
);
}
const draft = this.draft ?? '2020-12';
const draft = this.draft ?? this._draftFor(schema);
// Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible
const validator = new Validator(schema as ConstructorParameters<typeof Validator>[0], draft, this.shortcircuit);

View file

@ -0,0 +1,61 @@
/**
* Declared-dialect classification shared by the default validator providers.
*/
import type { JsonSchemaType } from './types';
/**
* Canonical `$schema` URIs per supported dialect (http + https variants, trailing-`#` stripped).
*/
const DRAFT_2020_12_URIS: ReadonlySet<string> = new Set([
'https://json-schema.org/draft/2020-12/schema',
'http://json-schema.org/draft/2020-12/schema'
]);
const DRAFT_2019_09_URIS: ReadonlySet<string> = new Set([
'https://json-schema.org/draft/2019-09/schema',
'http://json-schema.org/draft/2019-09/schema'
]);
const DRAFT_07_URIS: ReadonlySet<string> = new Set(['https://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema']);
const DRAFT_06_URIS: ReadonlySet<string> = new Set(['https://json-schema.org/draft-06/schema', 'http://json-schema.org/draft-06/schema']);
/**
* Dialects the default providers dispatch on. draft-06 maps to `'draft-7'`: draft-07 only adds
* keywords over draft-06 (`if`/`then`/`else`), and enforcing them on a draft-06 schema is the
* accepted downlevel.
*/
export type DeclaredDialect = '2020-12' | '2019-09' | 'draft-7';
/**
* Whether a `$schema` value declares the 2019-09 dialect — the only supported dialect with
* `$recursiveRef`/`$recursiveAnchor`. Non-throwing (unlike {@linkcode declaredDialect}) so
* wire-layer callers can consult it for documents whose dialect may be unsupported.
*/
export function declares2019Dialect($schema: unknown): boolean {
return typeof $schema === 'string' && DRAFT_2019_09_URIS.has($schema.replace(/#$/, ''));
}
/**
* Classify a schema's declared `$schema` dialect. No `$schema` (or a non-string one) means
* 2020-12. Any other dialect throws a plain `Error` with a clear message rather than letting the
* engine crash on an opaque internal error or silently mis-validate; `remedy` names the calling
* provider's escape hatch in that message.
*/
export function declaredDialect(schema: JsonSchemaType, remedy: string): DeclaredDialect {
if (!('$schema' in schema) || typeof schema.$schema !== 'string') {
return '2020-12';
}
const declared = schema.$schema.replace(/#$/, '');
if (DRAFT_2020_12_URIS.has(declared)) {
return '2020-12';
}
if (DRAFT_2019_09_URIS.has(declared)) {
return '2019-09';
}
if (DRAFT_07_URIS.has(declared) || DRAFT_06_URIS.has(declared)) {
return 'draft-7';
}
throw new Error(
`JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). ` +
`The default validator supports JSON Schema 2020-12, 2019-09, draft-07, and draft-06; ${remedy}`
);
}

View file

@ -14,6 +14,8 @@
* and never be re-derived in shared/ or server-side code.
*/
import { declares2019Dialect } from '../../validators/dialects';
/**
* Whether a JSON Schema's root is non-object: either an explicit non-object
* `type`, or a typeless root such as `{anyOf:[…]}`. Object-shaped typeless
@ -44,9 +46,63 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet<string> = new Set([
'patternProperties',
'$defs',
'definitions',
'dependentSchemas'
'dependentSchemas',
// draft-07's dependentSchemas predecessor; its array-of-strings form is
// unaffected (string arrays contain no refs).
'dependencies'
]);
/**
* Whether a subtree's `$id` establishes a new resolution base. A fragment-only
* `$id` (`"#item"`, the draft-07/06 spelling of 2020-12's `$anchor`) does not
* change the RFC 3986 base URI — same-document pointers inside still resolve
* against the document root and must be rewritten.
*/
function establishesNewBase(id: unknown): boolean {
return id !== undefined && !(typeof id === 'string' && id.startsWith('#'));
}
/*
* Reference/base-affecting keyword coverage across the four supported dialects
* (2020-12, 2019-09, draft-07, draft-06). Every entry is rewritten, position-guarded,
* or N/A with the reason; legacyWrap.test.ts pins each handled row:
* - `$ref` (all dialects): JSON-Pointer forms `#`/`#/…` rewritten; other values untouched.
* - `$dynamicRef` (2020-12): pointer forms rewritten like `$ref`; plain-name form (`#name`)
* untouched — see `$anchor`.
* - `$dynamicAnchor` (2020-12): N/A — location-independent plain name; the envelope adds no
* anchors and the natural schema moves whole, so dynamic resolution is unchanged.
* - `$anchor` (2019-09/2020-12): N/A — same as `$dynamicAnchor`; `#name` refs are fragments,
* not pointers, and are never rewritten.
* - `$recursiveRef` (2019-09): handled only in documents whose DECLARED dialect is 2019-09.
* Elsewhere it is off-spec input copied verbatim — KNOWN LIMITATION: the engines already
* disagree on it there (only classic Ajv ignores it; Ajv2020 and @cfworker enforce it in
* every draft mode), so no consistent wrap behavior exists — the verbatim `#` mis-resolves
* to the envelope on the enforcing legs, while converting would manufacture a constraint
* on the classic-Ajv leg. Inherent to envelope relocation plus engine disagreement; the
* escape is the 2026 era or authoring the schema with a proper 2019-09 stamp. Within
* 2019-09: value restricted to `#`, and per core §8.2.4.2.1 a root-base ref is dynamic
* only when the DOCUMENT ROOT carries `$recursiveAnchor: true` (a non-root anchor can
* never be the initial target and is inert). Root-anchor-less documents: converted to the
* statically equivalent `$ref: '#/properties/result'`; when `$ref` co-occurs (legal,
* conjunctive in 2019-09) the conversion joins as an `allOf` entry instead. Documents
* whose root carries `$recursiveAnchor: true`: left verbatim — KNOWN LIMITATION:
* relocation cannot preserve dynamic re-resolution (a static rewrite would freeze it and
* the envelope root carries no anchor), so root-anchored recursion still mis-resolves on
* the 2025 projection.
* - `$recursiveAnchor` (2019-09): boolean, not a location — only the ROOT occurrence gates the
* conversion above; nested occurrences are copied verbatim and stay inert.
* - `$id`, URI form (all dialects): establishes a new base — subtree skipped (root and nested).
* - `$id`, fragment form (draft-07/06 anchor spelling; illegal in 2019-09/2020-12): does not
* change the base — descended into.
* - `$schema`: hoisted to the wrapper root so dialect dispatch and the graceful
* unsupported-dialect rejection see it.
* - `$vocabulary` (2019-09/2020-12): N/A — meta-schema-only keyword, inert in tool schemas.
* - Name→subschema maps (`properties`, `patternProperties`, `$defs`, `definitions`,
* `dependentSchemas`, draft-07/06 `dependencies`): entries are name positions; their keys
* are never treated as keywords.
* - Data-position keywords (`const`/`enum`/`default`/`examples`): values are instance data,
* never descended into.
*/
/**
* Wrap a non-object output schema in the 2025-era envelope:
* `{type:'object', properties:{result:<natural>}, required:['result']}`.
@ -60,33 +116,47 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet<string> = new Set([
* The rewrite is position-aware: data-valued keywords
* (`const`/`enum`/`default`/`examples`) in keyword position are NOT descended
* into; the same names appearing as property names under
* `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas`
* ARE descended into (they're subschemas). The rewrite is also `$id`-scoped:
* if the natural root carries `$id` no pointer is rewritten (same-document
* refs inside resolve against the embedded `$id` base, not the wrapper root),
* and any subtree that establishes its own `$id` is left untouched for the
* same reason.
* `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas`/
* `dependencies` ARE descended into (they're subschemas). The rewrite is also
* `$id`-scoped: if the natural root carries a base-establishing `$id` no
* pointer is rewritten (same-document refs inside resolve against the embedded
* `$id` base, not the wrapper root), and any subtree that establishes its own
* `$id` is left untouched for the same reason. Fragment-only `$id` (`"#item"`,
* draft-07's anchor spelling) does not establish a base and IS descended into.
*/
export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unknown>>): Record<string, unknown> {
// A root `$schema` is hoisted to the wrapper root: it's a document-level
// dialect declaration and the SEP-1613 dialect checks (both built-in
// providers) only inspect the root, so leaving it under `properties.result`
// would make a non-2020-12 schema pass the dialect check on the 2025
// projection while the same tool is rejected on the 2026 era.
// dialect declaration and the built-in providers' dialect dispatch only
// inspects the root, so leaving it under `properties.result` would make
// the wrapper compile under the default 2020-12 engine (an opaque Ajv2020
// compile error on draft-07 tuple-form `items` instead of the classic
// engine's tuple semantics) while the same tool dispatches to the declared
// dialect on the 2026 era — and hide an unsupported dialect from the
// graceful rejection.
const $schema = typeof natural['$schema'] === 'string' ? natural['$schema'] : undefined;
// `$id` at the natural root: every same-document `#/…` ref inside resolves
// against that base URI, not against the wrapper root — skip the rewrite.
if (natural['$id'] !== undefined) {
// A base-establishing `$id` at the natural root: every same-document `#/…`
// ref inside resolves against that base URI, not against the wrapper root —
// skip the rewrite. Fragment-only `$id` keeps the document-root base.
if (establishesNewBase(natural['$id'])) {
return { ...($schema !== undefined && { $schema }), type: 'object', properties: { result: natural }, required: ['result'] };
}
// `$recursiveRef` is a 2019-09-only keyword: the conversion applies solely to documents
// whose DECLARED dialect is 2019-09 (one document-level check). Elsewhere it is copied
// verbatim — converting would manufacture a constraint on the classic-Ajv leg, which
// ignores the member; the legs that enforce it are a KNOWN LIMITATION (coverage block).
// Within 2019-09, a root-base `$recursiveRef: '#'` is dynamic only when the DOCUMENT
// ROOT carries `$recursiveAnchor: true` (core §8.2.4.2.1); otherwise it is statically
// `$ref: '#'`-equivalent and is converted. See the coverage block above.
const convertRecursiveRefs = declares2019Dialect(natural['$schema']) && natural['$recursiveAnchor'] !== true;
const rewriteRefs = (node: unknown, parentIsNameMap: boolean): unknown => {
if (Array.isArray(node)) return node.map(item => rewriteRefs(item, false));
if (node === null || typeof node !== 'object') return node;
// A nested `$id` establishes its own resolution base for the subtree —
// A nested base-establishing `$id` owns resolution for the subtree —
// same-document refs inside are no longer relative to the wrapper root.
// Only applies in keyword position (a property NAMED `$id` is just a name).
if (!parentIsNameMap && (node as Record<string, unknown>)['$id'] !== undefined) return node;
if (!parentIsNameMap && establishesNewBase((node as Record<string, unknown>)['$id'])) return node;
const out: Record<string, unknown> = {};
let convertedRecursion = false;
for (const [k, v] of Object.entries(node)) {
if (parentIsNameMap) {
// Name position: `k` is an author-chosen property/def name, `v` is a
@ -94,6 +164,10 @@ export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unkno
out[k] = rewriteRefs(v, false);
} else if ((k === '$ref' || k === '$dynamicRef') && typeof v === 'string') {
out[k] = v === '#' ? '#/properties/result' : v.startsWith('#/') ? `#/properties/result${v.slice(1)}` : v;
} else if (k === '$recursiveRef' && v === '#' && convertRecursiveRefs) {
// Emitted after the loop: plain `$ref` when free, `allOf` join when `$ref`
// co-occurs ($ref and $recursiveRef are conjunctive in-place applicators).
convertedRecursion = true;
} else if (REF_REWRITE_DATA_POSITION_KEYS.has(k)) {
out[k] = v;
} else if (REF_REWRITE_NAME_MAP_KEYS.has(k)) {
@ -102,6 +176,13 @@ export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unkno
out[k] = rewriteRefs(v, false);
}
}
if (convertedRecursion) {
if ('$ref' in out) {
out['allOf'] = [...(Array.isArray(out['allOf']) ? out['allOf'] : []), { $ref: '#/properties/result' }];
} else {
out['$ref'] = '#/properties/result';
}
}
return out;
};
return {

View file

@ -46,12 +46,29 @@ describe('AjvJsonSchemaValidator lazy engine construction', () => {
expect(engineSlot(provider)).toBe(fake);
});
it('still rejects non-2020-12 $schema dialects before constructing the default engine', () => {
it('still rejects unknown $schema dialects before constructing the default engine', () => {
const provider = new AjvJsonSchemaValidator();
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' })).toThrow(
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-04/schema#', type: 'object' })).toThrow(
/unsupported dialect/
);
// The dialect check fires before engine construction — no engine was built for the rejected schema.
expect(engineSlot(provider)).toBeUndefined();
});
it('a draft-07 schema builds only the draft-07 engine, not the 2020-12 one', () => {
const provider = new AjvJsonSchemaValidator();
const validate = provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' });
expect(validate({}).valid).toBe(true);
expect(engineSlot(provider)).toBeUndefined();
expect((provider as unknown as { _ajvDraft7: unknown })._ajvDraft7).toBeDefined();
});
it('a 2019-09 schema builds only the 2019-09 engine', () => {
const provider = new AjvJsonSchemaValidator();
const validate = provider.getValidator({ $schema: 'https://json-schema.org/draft/2019-09/schema', type: 'object' });
expect(validate({}).valid).toBe(true);
expect(engineSlot(provider)).toBeUndefined();
expect((provider as unknown as { _ajvDraft7: unknown })._ajvDraft7).toBeUndefined();
expect((provider as unknown as { _ajv2019: unknown })._ajv2019).toBeDefined();
});
});

View file

@ -11,6 +11,7 @@ import { vi } from 'vitest';
import { Ajv, AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider';
import { declaredDialect } from '../../src/validators/dialects';
import type { JsonSchemaType } from '../../src/validators/types';
// Test with both AJV and CfWorker validators
@ -625,15 +626,17 @@ describe('Missing dependencies', () => {
});
/**
* SEP-1613 declares JSON Schema 2020-12 the dialect for tool schemas. The built-in providers
* validate as 2020-12 only: a schema with no `$schema` (or `$schema: …2020-12…`) compiles; a
* schema declaring any other `$schema` is rejected with a clear `Error`. The escape hatch is
* the existing custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`).
* The spec honors a schema's declared `$schema` dialect (absent means 2020-12 per SEP-1613).
* The built-in providers dispatch: no `$schema` / 2020-12 → the 2020-12 engine; draft-07 /
* draft-06 → a draft-07 engine (draft-07's changes over draft-06 are additive). Any other
* declared dialect is rejected with a clear `Error`. The escape hatch is the existing
* custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`).
*
* Discriminator: `prefixItems` is a 2020-12 keyword that the draft-07 Ajv class silently
* ignores under `strict:false`, so it proves the default engine is `Ajv2020`.
* Discriminators: `prefixItems` is a 2020-12 keyword the draft-07 engines silently ignore
* under lenient options, and the positional `items` array is draft-07's tuple form —
* together they prove which engine ran, not merely that compile stopped throwing.
*/
describe('SEP-1613 $schema dialect handling (2020-12 only)', () => {
describe('$schema dialect dispatch', () => {
const DRAFT_07_URI = 'http://json-schema.org/draft-07/schema#';
const DRAFT_2020_URI = 'https://json-schema.org/draft/2020-12/schema';
const prefixItemsSchema = ($schema?: string): JsonSchemaType => ({
@ -643,6 +646,13 @@ describe('SEP-1613 $schema dialect handling (2020-12 only)', () => {
});
/** Violates `prefixItems` (positions swapped). */
const PREFIX_ITEMS_BAD: unknown = ['x', 1];
/** Draft-07 tuple form (positional `items` array — not representable in the 2020-12-shaped type). */
const tupleItemsSchema = ($schema: string): JsonSchemaType =>
({
$schema,
type: 'array',
items: [{ type: 'number' }, { type: 'string' }]
}) as unknown as JsonSchemaType;
describe.each(validators)('$name', ({ provider }) => {
it('default → Ajv2020 / 2020-12 (prefixItems is enforced)', () => {
@ -656,17 +666,143 @@ describe('SEP-1613 $schema dialect handling (2020-12 only)', () => {
expect(v(PREFIX_ITEMS_BAD).valid).toBe(false);
});
it('$schema: draft-07 → graceful Error', () => {
expect(() => provider.getValidator(prefixItemsSchema(DRAFT_07_URI))).toThrow(/unsupported dialect.*2020-12 only/);
it.each([
['draft-07 http, trailing #', 'http://json-schema.org/draft-07/schema#'],
['draft-07 https, no #', 'https://json-schema.org/draft-07/schema'],
['draft-06 http', 'http://json-schema.org/draft-06/schema#'],
['draft-06 https', 'https://json-schema.org/draft-06/schema'],
['2019-09 https, trailing #', 'https://json-schema.org/draft/2019-09/schema#'],
['2019-09 http', 'http://json-schema.org/draft/2019-09/schema']
])('$schema %s → declared-dialect tuple semantics on `items`', (_label, uri) => {
const v = provider.getValidator(tupleItemsSchema(uri));
expect(v([1, 'x']).valid).toBe(true);
expect(v(['x', 1]).valid).toBe(false);
});
it('$schema: 2019-09 → graceful Error', () => {
expect(() => provider.getValidator(prefixItemsSchema('https://json-schema.org/draft/2019-09/schema'))).toThrow(
/unsupported dialect/
it.each([
['draft-04', 'http://json-schema.org/draft-04/schema#'],
['version-less alias', 'http://json-schema.org/schema#'],
['garbage', 'https://example.com/my-dialect']
])('$schema %s → graceful Error listing supported dialects', (_label, uri) => {
expect(() => provider.getValidator(prefixItemsSchema(uri))).toThrow(
/unsupported dialect.*2020-12, 2019-09, draft-07, and draft-06/s
);
});
});
// The shared classifier is what both providers dispatch on — pinning it directly covers
// the CfWorker side, whose engine applies both keyword sets in either draft mode (so the
// dispatch is not observable through validation results there).
describe('declaredDialect classifier', () => {
it.each([
['absent', undefined],
['https', 'https://json-schema.org/draft/2020-12/schema'],
['http, trailing #', 'http://json-schema.org/draft/2020-12/schema#']
])('%s → 2020-12', (_label, uri) => {
expect(declaredDialect({ ...(uri ? { $schema: uri } : {}), type: 'object' }, 'r')).toBe('2020-12');
});
it.each([
['https, trailing #', 'https://json-schema.org/draft/2019-09/schema#'],
['http', 'http://json-schema.org/draft/2019-09/schema']
])('2019-09 %s → 2019-09', (_label, uri) => {
expect(declaredDialect({ $schema: uri, type: 'object' }, 'r')).toBe('2019-09');
});
it.each([
['draft-07 http #', 'http://json-schema.org/draft-07/schema#'],
['draft-07 https', 'https://json-schema.org/draft-07/schema'],
['draft-06 http #', 'http://json-schema.org/draft-06/schema#'],
['draft-06 https', 'https://json-schema.org/draft-06/schema']
])('%s → draft-7', (_label, uri) => {
expect(declaredDialect({ $schema: uri, type: 'object' }, 'r')).toBe('draft-7');
});
it('unknown → throws with the remedy appended', () => {
expect(() => declaredDialect({ $schema: 'https://example.com/dialect', type: 'object' }, 'REMEDY.')).toThrow(
/unsupported dialect.*2020-12, 2019-09, draft-07, and draft-06; REMEDY\.$/s
);
});
});
it('AJV: declared 2019-09 selects Ajv2019 (unevaluatedProperties enforced, tuple items compiles)', () => {
// Pins the engine against both wrong-engine mutations: Ajv2020 would reject the
// `items` array form at compile, and classic Ajv ignores `unevaluatedProperties`.
// (No CfWorker leg: @cfworker/json-schema applies both keyword sets in every draft mode.)
const v = new AjvJsonSchemaValidator().getValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: { pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] } },
unevaluatedProperties: false
} as unknown as JsonSchemaType);
expect(v({ pair: [1, 'x'] }).valid).toBe(true);
expect(v({ pair: ['x', 1] }).valid).toBe(false); // tuple semantics live
expect(v({ pair: [1, 'x'], extra: 1 }).valid).toBe(false); // 2019-09 keyword enforced
});
it('recorded contract: cfworker throws on $ref inside a keyword-named draft-07 `dependencies` entry', () => {
// @cfworker/json-schema does not treat draft-07 `dependencies` as a name→subschema map
// during ref collection: an entry keyed like a keyword (`type` here) with a `$ref`
// inside is skipped, and validation THROWS `Unresolved $ref` when the dependency
// triggers — data-dependently (compile succeeds; non-triggering data validates).
// Through Client.callTool this surfaces as the SDK's TYPED validation error
// (ProtocolError InvalidParams "Failed to validate structured content: …" — the
// structuredContent validation catch wraps any engine throw), never as an unhandled
// exception. The Node engine (classic Ajv) handles the same schema correctly. This
// test records that difference so a dependency bump that changes either side is caught.
const schema: JsonSchemaType = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
definitions: { addr: { type: 'string' } },
properties: { type: { type: 'string' }, billing: {} },
dependencies: { type: { properties: { billing: { $ref: '#/definitions/addr' } } } }
} as unknown as JsonSchemaType;
const triggering = { type: 'card', billing: 'x' };
const cf = new CfWorkerJsonSchemaValidator().getValidator(schema);
expect(cf({ billing: 'x' }).valid).toBe(true); // dependency not triggered
expect(() => cf(triggering)).toThrow(/Unresolved \$ref/);
const ajv = new AjvJsonSchemaValidator().getValidator(schema);
expect(ajv(triggering).valid).toBe(true);
expect(ajv({ type: 'card', billing: 5 }).valid).toBe(false);
});
it('recorded contract: the engines DIVERGE on $ref siblings under draft-07', () => {
// Draft-07 says keywords adjacent to $ref MUST be ignored. Classic Ajv (v8)
// evaluates them anyway — non-configurable, and identical to v1's default
// engine — while @cfworker follows the spec. This test records that known
// difference so a dependency bump that changes either side is caught.
const schema: JsonSchemaType = {
$schema: DRAFT_07_URI,
type: 'object',
definitions: { name: { type: 'string' } },
properties: { a: { $ref: '#/definitions/name', maxLength: 2 } },
required: ['a']
} as unknown as JsonSchemaType;
const data = { a: 'hello' };
// Node engine: maxLength next to $ref is enforced → rejected (stricter than spec).
expect(new AjvJsonSchemaValidator().getValidator(schema)(data).valid).toBe(false);
// cfworker engine: sibling ignored per draft-07 → accepted.
expect(new CfWorkerJsonSchemaValidator().getValidator(schema)(data).valid).toBe(true);
});
it('AJV: declared draft-07 selects the draft-07 ENGINE (prefixItems ignored, items enforced)', () => {
// Contradictory keywords: under the classic engine the `items` tuple wins and
// `prefixItems` is unknown; `Ajv2020` would enforce `prefixItems` and reject the `items`
// array form at compile — so [1,'x'] passing pins the engine. (No CfWorker leg:
// @cfworker/json-schema applies both keyword sets in either draft mode.)
const v = new AjvJsonSchemaValidator().getValidator({
$schema: DRAFT_07_URI,
type: 'array',
items: [{ type: 'number' }, { type: 'string' }],
prefixItems: [{ type: 'string' }, { type: 'number' }]
} as unknown as JsonSchemaType);
expect(v([1, 'x']).valid).toBe(true);
expect(v(['x', 1]).valid).toBe(false);
});
it('AJV: custom Ajv instance bypasses the $schema check (caller owns dialect)', () => {
// A draft-07 Ajv passed explicitly: even with `$schema: draft-07`, the provider does not
// throw — and `prefixItems` is unknown to draft-07 Ajv and silently ignored.

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { JsonSchemaType } from '../../src/validators/types';
import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
import { wrapOutputSchemaForLegacy } from '../../src/wire/rev2025-11-25/legacyWrap';
import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec';
@ -156,3 +158,235 @@ describe('rev2025Codec.projectCallToolResult: value-shape wrap', () => {
expect(out.structuredContent).toEqual({ result: { a: 1 } });
});
});
describe('wrapOutputSchemaForLegacy: draft-07 idioms (declared-dialect schemas flow since the validators dispatch on $schema)', () => {
const DRAFT_07 = 'http://json-schema.org/draft-07/schema#';
it('`dependencies` is a name→subschema map: entries keyed like data keywords (default/…) are still rewritten', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: DRAFT_07,
anyOf: [{ type: 'object', dependencies: { default: { $ref: '#/definitions/rule' }, other: { $ref: '#/definitions/rule' } } }],
definitions: { rule: { type: 'string' } }
});
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'default')).toEqual({
$ref: '#/properties/result/definitions/rule'
});
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'other')).toEqual({
$ref: '#/properties/result/definitions/rule'
});
});
it('a `dependencies` entry keyed `$id` is a name position — it does not suppress rewriting of the map', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: DRAFT_07,
anyOf: [{ type: 'object', dependencies: { $id: { $ref: '#/definitions/rule' }, other: { $ref: '#/definitions/rule' } } }],
definitions: { rule: { type: 'string' } }
});
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', '$id')).toEqual({
$ref: '#/properties/result/definitions/rule'
});
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'other')).toEqual({
$ref: '#/properties/result/definitions/rule'
});
});
it('draft-07 array-of-strings `dependencies` entries pass through untouched', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: DRAFT_07,
anyOf: [{ type: 'object', dependencies: { a: ['b', 'c'] } }]
});
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'a')).toEqual(['b', 'c']);
});
it('a fragment-only nested $id (draft-07 anchor spelling) does not establish a new base — inner refs are rewritten', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: DRAFT_07,
type: 'array',
items: { $id: '#item', type: 'object', properties: { node: { $ref: '#/definitions/Node' } } },
definitions: { Node: { type: 'number' } }
});
expect(dig(wrapped, 'properties', 'result', 'items', '$id')).toBe('#item');
expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'node')).toEqual({
$ref: '#/properties/result/definitions/Node'
});
});
it('a fragment-only ROOT $id does not suppress the rewrite either', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: DRAFT_07,
$id: '#root',
type: 'array',
items: { $ref: '#/definitions/Node' },
definitions: { Node: { type: 'number' } }
});
expect(dig(wrapped, 'properties', 'result', 'items')).toEqual({ $ref: '#/properties/result/definitions/Node' });
});
it('a URI-valued $id still suppresses the rewrite (nested and root)', () => {
const nested = wrapOutputSchemaForLegacy({
type: 'array',
items: { $id: 'https://example.com/item', properties: { node: { $ref: '#/definitions/Node' } } },
definitions: { Node: { type: 'number' } }
});
expect(dig(nested, 'properties', 'result', 'items', 'properties', 'node')).toEqual({ $ref: '#/definitions/Node' });
const root = wrapOutputSchemaForLegacy({
$id: 'https://example.com/root',
type: 'array',
items: { $ref: '#/definitions/Node' },
definitions: { Node: { type: 'number' } }
});
expect(dig(root, 'properties', 'result', 'items')).toEqual({ $ref: '#/definitions/Node' });
});
});
describe('wrapOutputSchemaForLegacy: 2019-09 recursion ($recursiveRef/$recursiveAnchor)', () => {
const URI_2019 = 'https://json-schema.org/draft/2019-09/schema';
it('anchor-less $recursiveRef:"#" is converted to a static $ref at the relocated root', () => {
// 2019-09 restricts $recursiveRef to "#"; with no $recursiveAnchor in the document
// it is equivalent to $ref:"#", so the wrap converts it to the rewritten pointer.
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }
});
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $ref: '#/properties/result' });
});
it('converted recursion actually validates recursive values (engine leg)', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }
});
const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType);
expect(v({ result: [1, [2, 3]] }).valid).toBe(true);
expect(v({ result: [1, 'x'] }).valid).toBe(false);
});
it('with a $recursiveAnchor in the document, $recursiveRef is left verbatim (documented limitation)', () => {
// Dynamic re-resolution cannot be preserved under relocation: a static rewrite would
// freeze the ref, and the envelope root carries no anchor. Left as authored.
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
$recursiveAnchor: true,
type: 'array',
items: { $recursiveRef: '#' }
});
expect(dig(wrapped, 'properties', 'result', 'items')).toEqual({ $recursiveRef: '#' });
expect(dig(wrapped, 'properties', 'result', '$recursiveAnchor')).toBe(true);
});
it('properties NAMED $recursiveRef/$recursiveAnchor are name positions — no conversion, no anchor detection', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: {
type: 'object',
// a property literally named $recursiveAnchor (boolean-schema `true`) must
// not suppress conversion...
properties: { $recursiveAnchor: true, $recursiveRef: { type: 'string' } },
anyOf: [{ $recursiveRef: '#' }, { type: 'number' }]
}
});
// ...and the keyword-position occurrence still converts.
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 0)).toEqual({ $ref: '#/properties/result' });
// Name-position entries are untouched.
expect(dig(wrapped, 'properties', 'result', 'items', 'properties', '$recursiveRef')).toEqual({ type: 'string' });
});
it('plain-name anchor refs ("#name") are never rewritten; patternProperties/dependentSchemas are name maps', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: {
type: 'object',
patternProperties: { '^d': { $ref: '#/$defs/D' } },
dependentSchemas: { default: { $ref: '#/$defs/D' } },
properties: { a: { $ref: '#node' } }
},
$defs: { D: { type: 'string' }, N: { $anchor: 'node', type: 'number' } }
});
expect(dig(wrapped, 'properties', 'result', 'items', 'patternProperties', '^d')).toEqual({ $ref: '#/properties/result/$defs/D' });
expect(dig(wrapped, 'properties', 'result', 'items', 'dependentSchemas', 'default')).toEqual({
$ref: '#/properties/result/$defs/D'
});
// "#node" is a location-independent plain-name anchor fragment, not a JSON Pointer.
expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'a')).toEqual({ $ref: '#node' });
});
});
describe('wrapOutputSchemaForLegacy: $recursiveRef gate precision (2019-09 core §8.2.4.2.1)', () => {
const URI_2019 = 'https://json-schema.org/draft/2019-09/schema';
it('a NON-ROOT $recursiveAnchor is inert for a root-base ref — conversion still applies', () => {
// Only a root $recursiveAnchor makes a root-base $recursiveRef dynamic; an anchor
// under $defs can never be the initial target and must not suppress conversion.
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] },
$defs: { unused: { $recursiveAnchor: true, type: 'string' } }
});
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $ref: '#/properties/result' });
const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType);
expect(v({ result: [1, [2, 3]] }).valid).toBe(true);
expect(v({ result: [1, 'x'] }).valid).toBe(false);
});
it('$ref and $recursiveRef co-occur conjunctively — conversion joins via allOf, both enforced', () => {
// 2019-09 in-place applicators: both $ref and $recursiveRef apply to the same object.
// The node keeps its (rewritten) $ref; the recursion converts into an allOf entry.
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: { $ref: '#/$defs/notHuge', $recursiveRef: '#' },
$defs: { notHuge: { maxItems: 3 } }
});
const items = dig(wrapped, 'properties', 'result', 'items') as Record<string, unknown>;
expect(items['$ref']).toBe('#/properties/result/$defs/notHuge');
expect(items['$recursiveRef']).toBeUndefined();
expect(items['allOf']).toEqual([{ $ref: '#/properties/result' }]);
const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType);
expect(v({ result: [[], [[]]] }).valid).toBe(true); // nested arrays, all ≤3 items
expect(v({ result: [[[], [], [], []]] }).valid).toBe(false); // violates $ref (maxItems)
expect(v({ result: [['x']] }).valid).toBe(false); // violates recursion (inner non-array)
});
it('co-occurrence with an existing allOf appends, not clobbers', () => {
const wrapped = wrapOutputSchemaForLegacy({
$schema: URI_2019,
type: 'array',
items: { $ref: '#/$defs/a', $recursiveRef: '#', allOf: [{ $ref: '#/$defs/b' }] },
$defs: { a: { minItems: 0 }, b: { maxItems: 3 } }
});
expect(dig(wrapped, 'properties', 'result', 'items', 'allOf')).toEqual([
{ $ref: '#/properties/result/$defs/b' },
{ $ref: '#/properties/result' }
]);
});
});
describe('wrapOutputSchemaForLegacy: $recursiveRef conversion is scoped to 2019-09-declared documents', () => {
// $recursiveRef is a 2019-09-only keyword: outside a 2019-09 stamp the wrap copies it
// verbatim — converting would manufacture a constraint on the classic-Ajv leg, which
// ignores the member (Ajv2020 and @cfworker enforce it in every mode; a KNOWN
// LIMITATION, see legacyWrap.ts). These rows pin the WRAP contract only, deliberately
// without an engine leg: off-spec input the engines disagree on has no consistent
// validation outcome to assert (an engine leg would fail on the three enforcing legs).
it.each([
['2020-12', 'https://json-schema.org/draft/2020-12/schema'],
['draft-07', 'http://json-schema.org/draft-07/schema#'],
['absent', undefined]
])('%s document: $recursiveRef member passes through verbatim', (_label, uri) => {
const wrapped = wrapOutputSchemaForLegacy({
...(uri ? { $schema: uri } : {}),
type: 'array',
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }
});
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $recursiveRef: '#' });
});
});

View file

@ -516,7 +516,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
'client:jsonschema:unsupported-dialect-graceful': {
source: 'sdk',
behavior:
'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect … 2020-12 only" message instead of having the underlying engine fail opaquely.'
'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise (2020-12, 2019-09, draft-07, and draft-06 are supported) is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect" message instead of having the underlying engine fail opaquely.'
},
'client:jsonschema:bad-schema-isolates-tool': {
source: 'sdk',

View file

@ -0,0 +1,164 @@
/**
* Ecosystem servers advertise tool schemas stamped with a draft-07 `$schema`
* (zod-to-json-schema's default output — e.g. the official Filesystem server).
* The spec honors the declared dialect (absent means 2020-12), so the default
* validator must dispatch on it instead of rejecting pre-wire with InvalidParams.
* Unknown dialects still produce the typed error.
*/
import { Client } from '@modelcontextprotocol/client';
import type { JsonSchemaType } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
import { fromJsonSchema, McpServer, Server } from '@modelcontextprotocol/server';
/** zod-to-json-schema default output shape, lifted from the official Filesystem server. */
const FILESYSTEM_STYLE_SCHEMA = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
content: { type: 'string' },
encoding: { type: 'string', enum: ['utf8', 'base64'] }
},
required: ['content'],
additionalProperties: false
} as const;
/** Draft-07 tuple form: positional `items` array (2020-12 moved this to `prefixItems`). */
const DRAFT_07_TUPLE_SCHEMA = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] }
},
required: ['pair']
} as const;
/** zod-to-json-schema `target: 'openAi'` output shape (also its `target: '2019-09'`). */
const OPENAI_TARGET_SCHEMA = {
$schema: 'https://json-schema.org/draft/2019-09/schema#',
type: 'object',
properties: {
summary: { type: 'string' },
score: { type: 'number' }
},
required: ['summary'],
additionalProperties: false
} as const;
/**
* A real low-level Server advertising a verbatim outputSchema without compiling it —
* the shape of a non-SDK ecosystem server. `structuredContent` comes from `results`
* keyed by tool name.
*/
async function connectPair(
outputSchema: unknown,
structuredContent: () => unknown
): Promise<{ client: Client; close: () => Promise<unknown> }> {
const server = new Server({ name: 'ecosystem-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/list', async () => ({
tools: [{ name: 'read_text_file', inputSchema: { type: 'object' }, outputSchema: outputSchema as JsonSchemaType }]
}));
server.setRequestHandler('tools/call', async () => ({
content: [{ type: 'text', text: 'ok' }],
structuredContent: structuredContent() as Record<string, unknown>
}));
const client = new Client({ name: 'test-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Populate the tools/list cache — output validation derives from the cached entry.
await client.listTools();
return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) };
}
describe('declared-dialect tool schemas end to end', () => {
test('draft-07 outputSchema (Filesystem-server shape) validates instead of failing pre-wire', async () => {
const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ content: 'hello', encoding: 'utf8' }));
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({
structuredContent: { content: 'hello', encoding: 'utf8' }
});
await close();
});
test('a VIOLATING result against a draft-07 outputSchema still fails validation', async () => {
// `content` missing — the draft-07 engine must actually run, not pass through.
const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ encoding: 'utf8' }));
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
await close();
});
test('draft-07 tuple `items` gets draft-07 positional semantics', async () => {
let pair: unknown = [1, 'x'];
const { client, close } = await connectPair(DRAFT_07_TUPLE_SCHEMA, () => ({ pair }));
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({ structuredContent: { pair: [1, 'x'] } });
pair = ['x', 1]; // violates the positional item schemas
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
await close();
});
test('2019-09 outputSchema (zod-to-json-schema openAi target shape) validates instead of failing pre-wire', async () => {
const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ summary: 'ok', score: 1 }));
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({
structuredContent: { summary: 'ok', score: 1 }
});
await close();
});
test('a VIOLATING result against a 2019-09 outputSchema still fails validation', async () => {
// `summary` missing — the 2019-09 engine must actually run, not pass through.
const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ score: 1 }));
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
await close();
});
test('unknown dialect still fails pre-wire with the typed error', async () => {
const { client, close } = await connectPair(
{ ...FILESYSTEM_STYLE_SCHEMA, $schema: 'http://json-schema.org/draft-04/schema#' },
() => ({ content: 'hello' })
);
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/invalid outputSchema.*unsupported dialect/s);
await close();
});
test('fromJsonSchema registers a draft-07 inputSchema and enforces it server-side', async () => {
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
mcpServer.registerTool(
'echo',
{
inputSchema: fromJsonSchema<{ content: string; encoding?: 'utf8' | 'base64' }>(
FILESYSTEM_STYLE_SCHEMA as unknown as JsonSchemaType
)
},
async args => ({ content: [{ type: 'text', text: JSON.stringify(args) }] })
);
const client = new Client({ name: 'test-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
await expect(client.callTool({ name: 'echo', arguments: { content: 'hi' } })).resolves.toMatchObject({
content: [{ type: 'text', text: expect.stringContaining('hi') }]
});
// Violates the draft-07 schema (`content` missing) — the server reports an input validation error.
await expect(client.callTool({ name: 'echo', arguments: { encoding: 'utf8' } })).resolves.toMatchObject({
isError: true,
content: [{ type: 'text', text: expect.stringContaining("must have required property 'content'") }]
});
await Promise.all([client.close(), clientTransport.close(), serverTransport.close()]);
});
});

View file

@ -986,3 +986,32 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
).rejects.toThrow(/^Elicitation response content does not match requested schema/);
});
}
describe('declared-dialect requestedSchema (default validator)', () => {
test('draft-07-stamped requestedSchema validates accepted content with a real engine', async () => {
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const requestedSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name']
} as const;
let content: Record<string, unknown> = { name: 'John' };
client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content }));
await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({
action: 'accept',
content: { name: 'John' }
});
content = { name: '' }; // violates minLength — the draft-07 engine actually runs
await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).rejects.toThrow(
/does not match requested schema/
);
});
});