Skip to content

Commit d2ce73c

Browse files
fix(validators): honor declared 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's default draft-07 output (e.g. the Filesystem reference server) failed pre-wire with InvalidParams before a request was ever sent. Dispatch on the declared dialect instead, via a classifier shared by both providers: no $schema or 2020-12 keeps the 2020-12 engine, draft-07/06 validate with a draft-07 engine (Ajv classic on Node, draft '7' on the cfworker provider), and unknown dialects still throw the typed error, now listing the supported dialects. Caller-supplied engines and explicit { draft } continue to bypass the dispatch.
1 parent 1e1392e commit d2ce73c

11 files changed

Lines changed: 359 additions & 92 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 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 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).

docs/migration/upgrade-to-v2.md

Lines changed: 10 additions & 9 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,9 +1454,11 @@ 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) — and a declared draft-07 or
1460+
draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other
1461+
`$schema` are rejected with `Error("…unsupported dialect…")`.
14611462
14621463
`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to
14631464
`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is
@@ -1469,8 +1470,8 @@ surfaced per-tool on `callTool`).
14691470
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
14701471
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
14711472
| `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 |
1473+
| 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`) |
1474+
| undeclared draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
14741475
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
14751476
14761477
A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`,

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

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,9 @@ import { Ajv as Draft7Ajv } from 'ajv';
66
import { Ajv2020 } from 'ajv/dist/2020.js';
77
import _addFormats from 'ajv-formats';
88

9+
import { declaredDialect } from './dialects';
910
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
1011

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-
2112
/** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */
2213
interface AjvLike {
2314
compile: (schema: unknown) => AjvValidateFunction;
@@ -35,8 +26,8 @@ interface AjvValidateFunction {
3526
/** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */
3627
const addFormats = _addFormats as unknown as typeof _addFormats.default;
3728

38-
function createDefaultAjvInstance(): AjvLike {
39-
const ajv = new Ajv2020({
29+
function createDefaultAjvInstance(engineClass: typeof Ajv2020 | typeof Draft7Ajv): AjvLike {
30+
const ajv = new engineClass({
4031
strict: false,
4132
validateFormats: true,
4233
validateSchema: false,
@@ -50,8 +41,10 @@ function createDefaultAjvInstance(): AjvLike {
5041
* AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv`
5142
* for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy).
5243
*
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
44+
* Default dispatches on the schema's declared dialect: no `$schema` or 2020-12 → `Ajv2020`
45+
* (SEP-1613); draft-07 or draft-06 → the classic draft-07 `Ajv` class (draft-07's changes over
46+
* draft-06 are additive, so one engine covers both). Schemas declaring any other `$schema` are
47+
* rejected with a plain `Error`; pass a pre-configured Ajv instance to validate
5548
* other dialects. The SDK bundles ajv internally but does not re-export `Ajv2020` (its type
5649
* graph tips downstream declaration bundling — see #2339). To construct a custom 2020-12
5750
* instance, add `ajv` to your own dependencies (matching the SDK's pinned version) and
@@ -80,15 +73,18 @@ function createDefaultAjvInstance(): AjvLike {
8073
*/
8174
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
8275
private _ajv: AjvLike | undefined;
83-
/** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */
76+
/** Lazy classic (draft-07) engine, built on the first draft-07/draft-06-declared schema. */
77+
private _ajvDraft7: AjvLike | undefined;
78+
/** True iff the constructor received a caller-supplied engine; the `$schema` dispatch is skipped. */
8479
private readonly _userAjv: boolean;
8580

8681
/**
8782
* @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is
8883
* 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
84+
* choice). When omitted, the provider constructs per-dialect engines (`Ajv2020`, and the
85+
* classic draft-07 `Ajv` for draft-07/06-declared schemas) with
9086
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
91-
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so
87+
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call needing each**, so
9288
* constructing the provider (e.g. as the default validator of a `Client`/`Server` that never
9389
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter
9490
* is typed structurally so consumers who don't pass an instance need not have `ajv` installed.
@@ -98,29 +94,30 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
9894
this._ajv = ajv;
9995
}
10096

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

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-
);
102+
/**
103+
* Pick the engine for a schema's declared dialect. A caller-supplied engine is used for
104+
* every schema — do not second-guess by `$schema` (bring-your-own-validator means
105+
* bring-your-own-dialect). Otherwise: no `$schema` or 2020-12 → `Ajv2020`; draft-07 or
106+
* draft-06 → classic `Ajv`; anything else → `Error`.
107+
*/
108+
private _engineFor(schema: JsonSchemaType): AjvLike {
109+
if (this._userAjv) {
110+
return this.ajv;
121111
}
112+
const dialect = declaredDialect(
113+
schema,
114+
'pass a pre-configured Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.'
115+
);
116+
return dialect === '2020-12' ? this.ajv : (this._ajvDraft7 ??= createDefaultAjvInstance(Draft7Ajv));
117+
}
122118

123-
const engine = this.ajv;
119+
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
120+
const engine = this._engineFor(schema);
124121
const ajvValidator =
125122
'$id' in schema && typeof schema.$id === 'string'
126123
? (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); 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, 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'`; draft-07
65+
* 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 === '2020-12' ? '2020-12' : '7';
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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Declared-dialect classification shared by the default validator providers.
3+
*/
4+
5+
import type { JsonSchemaType } from './types';
6+
7+
/**
8+
* Canonical `$schema` URIs per supported dialect (http + https variants, trailing-`#` stripped).
9+
*/
10+
const DRAFT_2020_12_URIS: ReadonlySet<string> = new Set([
11+
'https://json-schema.org/draft/2020-12/schema',
12+
'http://json-schema.org/draft/2020-12/schema'
13+
]);
14+
const DRAFT_07_URIS: ReadonlySet<string> = new Set(['https://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema']);
15+
const DRAFT_06_URIS: ReadonlySet<string> = new Set(['https://json-schema.org/draft-06/schema', 'http://json-schema.org/draft-06/schema']);
16+
17+
/**
18+
* Dialects the default providers dispatch on. draft-06 maps to `'draft-7'`: draft-07 only adds
19+
* keywords over draft-06 (`if`/`then`/`else`), and enforcing them on a draft-06 schema is the
20+
* accepted downlevel.
21+
*/
22+
export type DeclaredDialect = '2020-12' | 'draft-7';
23+
24+
/**
25+
* Classify a schema's declared `$schema` dialect. No `$schema` (or a non-string one) means
26+
* 2020-12. Any other dialect throws a plain `Error` with a clear message rather than letting the
27+
* engine crash on an opaque internal error or silently mis-validate; `remedy` names the calling
28+
* provider's escape hatch in that message.
29+
*/
30+
export function declaredDialect(schema: JsonSchemaType, remedy: string): DeclaredDialect {
31+
if (!('$schema' in schema) || typeof schema.$schema !== 'string') {
32+
return '2020-12';
33+
}
34+
const declared = schema.$schema.replace(/#$/, '');
35+
if (DRAFT_2020_12_URIS.has(declared)) {
36+
return '2020-12';
37+
}
38+
if (DRAFT_07_URIS.has(declared) || DRAFT_06_URIS.has(declared)) {
39+
return 'draft-7';
40+
}
41+
throw new Error(
42+
`JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). ` +
43+
`The default validator supports JSON Schema 2020-12, draft-07, and draft-06; ${remedy}`
44+
);
45+
}

packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet<string> = new Set([
6969
*/
7070
export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unknown>>): Record<string, unknown> {
7171
// A root `$schema` is hoisted to the wrapper root: it's a document-level
72-
// dialect declaration and the SEP-1613 dialect checks (both built-in
73-
// providers) only inspect the root, so leaving it under `properties.result`
74-
// would make a non-2020-12 schema pass the dialect check on the 2025
75-
// projection while the same tool is rejected on the 2026 era.
72+
// dialect declaration and the built-in providers' dialect dispatch only
73+
// inspects the root, so leaving it under `properties.result` would make
74+
// the wrapper validate under the default 2020-12 engine (e.g. draft-07
75+
// tuple-form `items` silently ignored) while the same tool dispatches to
76+
// the declared dialect on the 2026 era — and hide an unsupported dialect
77+
// from the graceful rejection.
7678
const $schema = typeof natural['$schema'] === 'string' ? natural['$schema'] : undefined;
7779
// `$id` at the natural root: every same-document `#/…` ref inside resolves
7880
// against that base URI, not against the wrapper root — skip the rewrite.

0 commit comments

Comments
 (0)