Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/validator-dialect-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/client': patch
---

The default validator now honors declared 2019-09 and draft-07/06 dialects instead of rejecting them: a schema stamped `"$schema": "http://json-schema.org/draft-07/schema#"` (zod-to-json-schema's default output) validates with draft-07 semantics, and a 2019-09 stamp (zod-to-json-schema's `2019-09`/`openAi` targets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no `$schema` still validate as 2020-12, and unknown dialects still produce the typed error (now listing the supported dialects: 2020-12, 2019-09, draft-07, draft-06).
37 changes: 23 additions & 14 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,10 +707,9 @@ the host side and register the result with `fromJsonSchema()`: zod-4 input via z
own `z.toJSONSchema(z.object(shape), { io: 'input', target: 'draft-2020-12' })` (the
conversion is runtime-structural, so a zod ≥4.2 in the host handles schemas built by a
different zod-4 copy), zod-3 input via the
[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Strip
the `$schema` member from the converted output before passing it to `fromJsonSchema()`
— `zod-to-json-schema` stamps a draft-07 `$schema` by default, and the default
validator [accepts 2020-12 only](#json-schema-2020-12-posture-sep-1613-sep-2106).
[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Its
default draft-07 `$schema` stamp is fine as-is — the default validator
[honors declared draft-07/06 dialects](#json-schema-2020-12-posture-sep-1613-sep-2106).

How a too-old zod surfaces depends on which entry point your code imports. With
main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly
Expand Down Expand Up @@ -1455,23 +1454,33 @@ classes — import it from one package consistently within a process.

#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106)

The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020`
instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas
declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`.
The default validator dispatches on the schema's declared `$schema`: absent or 2020-12
validates as **JSON Schema 2020-12** — on Node via `Ajv2020` instead of v1's draft-07
`Ajv` (the Cloudflare Workers default was already 2020-12) — a declared 2019-09
`$schema` validates with 2019-09 semantics (`Ajv2019`), and a declared draft-07 or
draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other
`$schema` are rejected with `Error("…unsupported dialect…")`. Two known draft-07 engine
differences: the Node engine (classic Ajv, same as v1's default) evaluates keywords
adjacent to `$ref`, stricter than draft-07's ignore-siblings rule, while the
browser/Workers engine ignores them per spec; and the browser/Workers engine does not
resolve a `$ref` inside a `dependencies` entry whose key collides with a JSON Schema
keyword (`type`, `default`, `format`, …) — validation throws `Unresolved $ref` when
that dependency triggers (surfaced as the SDK's typed validation error), while the
Node engine handles the same schema correctly.

`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to
`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is
`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External
`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile,
surfaced per-tool on `callTool`).

| v1 pattern | Mechanical fix |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) |
| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |
| v1 pattern | Mechanical fix |
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` |
| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` |
| draft-07 idioms **without** a declared `$schema` (a declared draft-07/06 `$schema` dispatches automatically) | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) |
| undeclared draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas |
| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` |

A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`,
etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward
Expand Down
79 changes: 44 additions & 35 deletions packages/core-internal/src/validators/ajvProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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));
}

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.`
);
/**
* 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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this still a default?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean here - default without a $schema specified remains 2020-12, the default in createDefault... means engine configuration in this case, but now supporting the different dialects instead of being coupled to 2020-12 specifically.

}
return (this._ajvDraft7 ??= createDefaultAjvInstance(Draft7Ajv));
}

const engine = this.ajv;
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
const engine = this._engineFor(schema);
const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (engine.getSchema(schema.$id) ?? engine.compile(schema))
Expand Down
Loading
Loading