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
7 changed files with 851 additions and 109 deletions

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: '#' });
});
});