Skip to content

Commit f130e1a

Browse files
fix(validators): honor declared draft-07/06 JSON Schema dialects instead of rejecting them (#2534)
1 parent 6dac272 commit f130e1a

12 files changed

Lines changed: 851 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 (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).

docs/migration/upgrade-to-v2.md

Lines changed: 23 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,33 @@ 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…")`. Two known draft-07 engine
1463+
differences: the Node engine (classic Ajv, same as v1's default) evaluates keywords
1464+
adjacent to `$ref`, stricter than draft-07's ignore-siblings rule, while the
1465+
browser/Workers engine ignores them per spec; and the browser/Workers engine does not
1466+
resolve a `$ref` inside a `dependencies` entry whose key collides with a JSON Schema
1467+
keyword (`type`, `default`, `format`, …) — validation throws `Unresolved $ref` when
1468+
that dependency triggers (surfaced as the SDK's typed validation error), while the
1469+
Node engine handles the same schema correctly.
14611470
14621471
`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to
14631472
`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is
14641473
`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External
14651474
`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile,
14661475
surfaced per-tool on `callTool`).
14671476
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` |
1477+
| v1 pattern | Mechanical fix |
1478+
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1479+
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
1480+
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
1481+
| 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`) |
1482+
| undeclared draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
1483+
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
14751484
14761485
A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`,
14771486
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))

0 commit comments

Comments
 (0)