Skip to content

Commit 0a8ab1c

Browse files
fix(validators): honor declared 2019-09/draft-07/06 JSON Schema dialects
The default validator rejected any schema declaring a non-2020-12 $schema, so tools from servers emitting zod-to-json-schema output failed pre-wire with InvalidParams before a request was ever sent — its default target stamps draft-07 (e.g. the Filesystem reference server), and its 2019-09/openAi targets stamp 2019-09. Dispatch on the declared dialect instead, via a classifier shared by both providers: no $schema or 2020-12 keeps the 2020-12 engine, 2019-09 validates with Ajv2019 (native 2019-09 on the cfworker provider), draft-07/06 validate with a draft-07 engine (Ajv classic on Node, draft '7' on cfworker), and unknown dialects still throw the typed error, now listing the supported dialects. Caller-supplied engines and explicit { draft } continue to bypass the dispatch. The 2025-era outputSchema wrapper's $ref rewriter learns the same vocabulary: draft-07 'dependencies' is a name-to-subschema map, and a fragment-only $id does not establish a new resolution base, so wrapped draft-07 schemas project correctly instead of failing to compile. The known classic-Ajv deviation on $ref sibling keywords (evaluated, stricter than draft-07's ignore rule, matching v1's default engine) is documented and pinned by a test.
1 parent 1e1392e commit 0a8ab1c

12 files changed

Lines changed: 586 additions & 109 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
'@modelcontextprotocol/client': patch
4+
---
5+
6+
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. 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).

docs/migration/upgrade-to-v2.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -707,10 +707,9 @@ the host side and register the result with `fromJsonSchema()`: zod-4 input via z
707707
own `z.toJSONSchema(z.object(shape), { io: 'input', target: 'draft-2020-12' })` (the
708708
conversion is runtime-structural, so a zod ≥4.2 in the host handles schemas built by a
709709
different zod-4 copy), zod-3 input via the
710-
[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Strip
711-
the `$schema` member from the converted output before passing it to `fromJsonSchema()`
712-
— `zod-to-json-schema` stamps a draft-07 `$schema` by default, and the default
713-
validator [accepts 2020-12 only](#json-schema-2020-12-posture-sep-1613-sep-2106).
710+
[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Its
711+
default draft-07 `$schema` stamp is fine as-is — the default validator
712+
[honors declared draft-07/06 dialects](#json-schema-2020-12-posture-sep-1613-sep-2106).
714713
715714
How a too-old zod surfaces depends on which entry point your code imports. With
716715
main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly
@@ -1455,23 +1454,29 @@ classes — import it from one package consistently within a process.
14551454
14561455
#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106)
14571456
1458-
The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020`
1459-
instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas
1460-
declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`.
1457+
The default validator dispatches on the schema's declared `$schema`: absent or 2020-12
1458+
validates as **JSON Schema 2020-12** — on Node via `Ajv2020` instead of v1's draft-07
1459+
`Ajv` (the Cloudflare Workers default was already 2020-12) — a declared 2019-09
1460+
`$schema` validates with 2019-09 semantics (`Ajv2019`), and a declared draft-07 or
1461+
draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other
1462+
`$schema` are rejected with `Error("…unsupported dialect…")`. One known draft-07 engine
1463+
difference: the Node engine (classic Ajv, same as v1's default) evaluates keywords
1464+
adjacent to `$ref`, stricter than draft-07's ignore-siblings rule; the browser/Workers
1465+
engine ignores them per spec.
14611466
14621467
`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to
14631468
`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is
14641469
`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External
14651470
`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile,
14661471
surfaced per-tool on `callTool`).
14671472
1468-
| v1 pattern | Mechanical fix |
1469-
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1470-
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
1471-
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
1472-
| 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`) |
1473-
| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
1474-
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
1473+
| v1 pattern | Mechanical fix |
1474+
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1475+
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
1476+
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
1477+
| 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`) |
1478+
| undeclared draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
1479+
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
14751480
14761481
A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`,
14771482
etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward

packages/core-internal/src/validators/ajvProvider.ts

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,13 @@
33
*/
44

55
import { Ajv as Draft7Ajv } from 'ajv';
6+
import { Ajv2019 } from 'ajv/dist/2019.js';
67
import { Ajv2020 } from 'ajv/dist/2020.js';
78
import _addFormats from 'ajv-formats';
89

10+
import { declaredDialect } from './dialects';
911
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
1012

11-
/**
12-
* Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema
13-
* declares anything else, the default provider throws a plain `Error` with a clear message rather
14-
* than letting the engine crash on an opaque internal error or silently mis-validate.
15-
*/
16-
const DRAFT_2020_12_URIS: ReadonlySet<string> = new Set([
17-
'https://json-schema.org/draft/2020-12/schema',
18-
'http://json-schema.org/draft/2020-12/schema'
19-
]);
20-
2113
/** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */
2214
interface AjvLike {
2315
compile: (schema: unknown) => AjvValidateFunction;
@@ -35,8 +27,8 @@ interface AjvValidateFunction {
3527
/** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */
3628
const addFormats = _addFormats as unknown as typeof _addFormats.default;
3729

38-
function createDefaultAjvInstance(): AjvLike {
39-
const ajv = new Ajv2020({
30+
function createDefaultAjvInstance(engineClass: typeof Ajv2020 | typeof Ajv2019 | typeof Draft7Ajv): AjvLike {
31+
const ajv = new engineClass({
4032
strict: false,
4133
validateFormats: true,
4234
validateSchema: false,
@@ -50,8 +42,13 @@ function createDefaultAjvInstance(): AjvLike {
5042
* AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv`
5143
* for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy).
5244
*
53-
* Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different
54-
* `$schema` are rejected with a plain `Error`; pass a pre-configured Ajv instance to validate
45+
* Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `Ajv2020`
46+
* (SEP-1613); 2019-09 → `Ajv2019`; draft-07 or draft-06 → the classic draft-07 `Ajv` class
47+
* (draft-07's changes over draft-06 are additive, so one engine covers both). Known draft-07 deviation: classic Ajv
48+
* evaluates keywords adjacent to `$ref` (stricter than draft-07's ignore-siblings rule, matching
49+
* v1's default engine), while the cfworker provider ignores them per spec.
50+
* Schemas declaring any other `$schema` are
51+
* rejected with a plain `Error`; pass a pre-configured Ajv instance to validate
5552
* other dialects. The SDK bundles ajv internally but does not re-export `Ajv2020` (its type
5653
* graph tips downstream declaration bundling — see #2339). To construct a custom 2020-12
5754
* instance, add `ajv` to your own dependencies (matching the SDK's pinned version) and
@@ -80,15 +77,20 @@ function createDefaultAjvInstance(): AjvLike {
8077
*/
8178
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
8279
private _ajv: AjvLike | undefined;
83-
/** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */
80+
/** Lazy classic (draft-07) engine, built on the first draft-07/draft-06-declared schema. */
81+
private _ajvDraft7: AjvLike | undefined;
82+
/** Lazy 2019-09 engine, built on the first 2019-09-declared schema. */
83+
private _ajv2019: AjvLike | undefined;
84+
/** True iff the constructor received a caller-supplied engine; the `$schema` dispatch is skipped. */
8485
private readonly _userAjv: boolean;
8586

8687
/**
8788
* @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is
8889
* used for **every** schema regardless of its declared `$schema` (the caller owns dialect
89-
* choice). When omitted, the provider constructs a single `Ajv2020` instance with
90+
* choice). When omitted, the provider constructs per-dialect engines (`Ajv2020`, `Ajv2019`,
91+
* and the classic draft-07 `Ajv` for draft-07/06-declared schemas) with
9092
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
91-
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so
93+
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call needing each**, so
9294
* constructing the provider (e.g. as the default validator of a `Client`/`Server` that never
9395
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter
9496
* 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 {
98100
this._ajv = ajv;
99101
}
100102

101-
/** The underlying engine — the default instance is created on first use. */
103+
/** The underlying 2020-12 engine — the default instance is created on first use. */
102104
private get ajv(): AjvLike {
103-
return (this._ajv ??= createDefaultAjvInstance());
105+
return (this._ajv ??= createDefaultAjvInstance(Ajv2020));
104106
}
105107

106-
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
107-
// Caller supplied a specific engine — do not second-guess by `$schema`
108-
// (bring-your-own-validator means bring-your-own-dialect).
109-
if (
110-
!this._userAjv &&
111-
'$schema' in schema &&
112-
typeof schema.$schema === 'string' &&
113-
!DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, ''))
114-
) {
115-
const declared = schema.$schema.slice(0, 200);
116-
throw new Error(
117-
`JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` +
118-
`The default validator supports JSON Schema 2020-12 only; pass a pre-configured ` +
119-
`Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.`
120-
);
108+
/**
109+
* Pick the engine for a schema's declared dialect. A caller-supplied engine is used for
110+
* every schema — do not second-guess by `$schema` (bring-your-own-validator means
111+
* bring-your-own-dialect). Otherwise: no `$schema` or 2020-12 → `Ajv2020`; 2019-09 →
112+
* `Ajv2019`; draft-07 or draft-06 → classic `Ajv`; anything else → `Error`.
113+
*/
114+
private _engineFor(schema: JsonSchemaType): AjvLike {
115+
if (this._userAjv) {
116+
return this.ajv;
117+
}
118+
const dialect = declaredDialect(
119+
schema,
120+
'pass a pre-configured Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.'
121+
);
122+
if (dialect === '2020-12') {
123+
return this.ajv;
124+
}
125+
if (dialect === '2019-09') {
126+
return (this._ajv2019 ??= createDefaultAjvInstance(Ajv2019));
121127
}
128+
return (this._ajvDraft7 ??= createDefaultAjvInstance(Draft7Ajv));
129+
}
122130

123-
const engine = this.ajv;
131+
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
132+
const engine = this._engineFor(schema);
124133
const ajvValidator =
125134
'$id' in schema && typeof schema.$id === 'string'
126135
? (engine.getSchema(schema.$id) ?? engine.compile(schema))

packages/core-internal/src/validators/cfWorkerProvider.ts

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,21 @@
1010

1111
import { Validator } from '@cfworker/json-schema';
1212

13+
import { declaredDialect } from './dialects';
1314
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
1415

1516
/**
1617
* JSON Schema draft version supported by `@cfworker/json-schema`.
1718
*/
1819
export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12';
1920

20-
/**
21-
* Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema
22-
* declares anything else and no `{draft}` is forced, the provider throws a plain `Error`.
23-
*/
24-
const DRAFT_2020_12_URIS: ReadonlySet<string> = new Set([
25-
'https://json-schema.org/draft/2020-12/schema',
26-
'http://json-schema.org/draft/2020-12/schema'
27-
]);
28-
2921
/**
3022
* `@cfworker/json-schema`-backed JSON Schema validator. See
3123
* `@modelcontextprotocol/{client,server}/validators/cf-worker` for the customisation entry point.
3224
*
33-
* Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different
34-
* `$schema` are rejected with a plain `Error`. Passing an explicit `draft` to the constructor
25+
* Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `'2020-12'`
26+
* (SEP-1613); 2019-09 → `'2019-09'`; draft-07 or draft-06 → `'7'`. Schemas declaring any other `$schema` are rejected
27+
* with a plain `Error`. Passing an explicit `draft` to the constructor
3528
* overrides this — that draft is used for every schema regardless of `$schema`.
3629
*
3730
* @example Use with default configuration (2020-12, shortcircuit on)
@@ -58,14 +51,24 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
5851
* @param options - Configuration options
5952
* @param options.shortcircuit - If `true`, stop validation after first error (default: `true`)
6053
* @param options.draft - JSON Schema draft version to force for every schema. When set, the
61-
* `$schema` check is skipped. When omitted, the provider validates as 2020-12 and rejects
62-
* schemas declaring a different `$schema`.
54+
* `$schema` dispatch is skipped. When omitted, the provider dispatches on each schema's
55+
* declared `$schema` (2020-12, 2019-09, draft-07, draft-06; absent means 2020-12) and rejects others.
6356
*/
6457
constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) {
6558
this.shortcircuit = options?.shortcircuit ?? true;
6659
this.draft = options?.draft;
6760
}
6861

62+
/**
63+
* Pick the engine draft for a schema's declared dialect (a caller-forced `{draft}` bypasses
64+
* this — do not second-guess by `$schema`). No `$schema` or 2020-12 → `'2020-12'`; 2019-09 →
65+
* `'2019-09'`; draft-07 or draft-06 → `'7'`; anything else → `Error`.
66+
*/
67+
private _draftFor(schema: JsonSchemaType): CfWorkerSchemaDraft {
68+
const dialect = declaredDialect(schema, 'pass an explicit { draft } to CfWorkerJsonSchemaValidator to validate other dialects.');
69+
return dialect === 'draft-7' ? '7' : dialect;
70+
}
71+
6972
/**
7073
* Create a validator for the given JSON Schema
7174
*
@@ -75,22 +78,7 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
7578
* @returns A validator function that validates input data
7679
*/
7780
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
78-
// Caller forced a draft — use it for everything; do not second-guess by `$schema`.
79-
if (
80-
this.draft === undefined &&
81-
'$schema' in schema &&
82-
typeof schema.$schema === 'string' &&
83-
!DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, ''))
84-
) {
85-
const declared = schema.$schema.slice(0, 200);
86-
throw new Error(
87-
`JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` +
88-
`The default validator supports JSON Schema 2020-12 only; pass an explicit ` +
89-
`{ draft } to CfWorkerJsonSchemaValidator to validate other dialects.`
90-
);
91-
}
92-
93-
const draft = this.draft ?? '2020-12';
81+
const draft = this.draft ?? this._draftFor(schema);
9482
// Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible
9583
const validator = new Validator(schema as ConstructorParameters<typeof Validator>[0], draft, this.shortcircuit);
9684

0 commit comments

Comments
 (0)