Skip to content
Open
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 (one documented engine difference: classic Ajv evaluates keywords alongside `$ref`, which draft-07 says to ignore — see 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).
33 changes: 19 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,29 @@ 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…")`. One known draft-07 engine
difference: the Node engine (classic Ajv, same as v1's default) evaluates keywords
adjacent to `$ref`, stricter than draft-07's ignore-siblings rule; the browser/Workers
engine ignores them per spec.

`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));
}
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
46 changes: 17 additions & 29 deletions packages/core-internal/src/validators/cfWorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,21 @@

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

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

/**
* JSON Schema draft version supported by `@cfworker/json-schema`.
*/
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`.
*
* @example Use with default configuration (2020-12, shortcircuit on)
Expand All @@ -58,14 +51,24 @@
* @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;
}

Check warning on line 71 in packages/core-internal/src/validators/cfWorkerProvider.ts

View check run for this annotation

Claude / Claude Code Review

cfworker draft-07 dispatch: 'dependencies' entries keyed by engine-ignored keyword names throw 'Unresolved $ref' at validate time

A second, undocumented draft-07 engine divergence: @cfworker/json-schema's dereference() doesn't treat draft-07 `dependencies` as a name→subschema map, so an entry whose key collides with the engine's ignored-keyword set (`type`, `default`, `format`, `required`, `pattern`, `id`, …) and contains a `$ref` is skipped during ref registration — the returned validator then throws `Unresolved $ref` data-dependently when the dependency triggers, surfacing as InvalidParams in `callTool` on browser/Worker
Comment on lines +63 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 A second, undocumented draft-07 engine divergence: @cfworker/json-schema's dereference() doesn't treat draft-07 dependencies as a name→subschema map, so an entry whose key collides with the engine's ignored-keyword set (type, default, format, required, pattern, id, …) and contains a $ref is skipped during ref registration — the returned validator then throws Unresolved $ref data-dependently when the dependency triggers, surfacing as InvalidParams in callTool on browser/Workers while the byte-identical schema validates correctly on the Node (classic Ajv) path. Consider documenting it alongside the $ref-siblings deviation (JSDoc + migration guide) and adding a cfworker engine-leg pinning test; longer-term, pre-normalizing draft-07 dependencies into dependentSchemas/dependentRequired before handing the schema to cfworker would close the gap.

Extended reasoning...

What the bug is. Routing declared draft-07/06 schemas to @cfworker/json-schema draft '7' (_draftFor, cfWorkerProvider.ts:63-71) makes draft-07 dependencies reachable through the default browser/Workers validator for the first time — but the pinned engine (4.1.1) has a gap the SDK now surfaces. Its dereference() does not list dependencies in its schemaMapKeyword set (only $defs/definitions/properties/patternProperties/dependentSchemas), so the dependencies map is walked in keyword position: any entry whose author-chosen property-name key collides with the engine's ignoredKeyword set (type, default, format, required, pattern, enum, const, id, $id, minimum, maxLength, …) is skipped entirely, and a $ref inside it is never registered. At validate time, when the dependency triggers, the engine throws Error('Unresolved $ref "#/definitions/…"') instead of returning a result.

Step-by-step proof (verified against the pinned @cfworker/json-schema 4.1.1 with the provider's exact construction, new Validator(schema, '7', true)):

const schema = {
  $schema: 'http://json-schema.org/draft-07/schema#',
  type: 'object',
  definitions: { addr: { type: 'string' } },
  properties: { type: { type: 'string' }, billing: {} },
  // canonical draft-07 idiom: 'when `type` is present, constrain `billing`'
  dependencies: { type: { properties: { billing: { $ref: '#/definitions/addr' } } } }
};

// cfworker (browser/Workers default path):
validate({ billing: 'x' })                // → valid: true   (dependency not triggered)
validate({ type: 'card', billing: 'x' })  // → THROWS Error('Unresolved $ref "#/definitions/addr"')

// classic Ajv (Node default path, provider's exact options):
// → true for valid data, false for invalid data — correct draft-07 behavior

The same throw reproduces for entry keys default, format, required, pattern, id, minimum; a non-colliding key (e.g. creditCard) validates correctly — confirming the dereference-skip mechanism. The engine's known-schemas map registers #/dependencies but not #/dependencies/type, so validate.js falls back to the raw pointer, which is not a lookup key, and throws.

Why existing safeguards don't catch it. The Validator constructor succeeds, so this is not caught by the per-tool compile-error isolation (client:jsonschema:bad-schema-isolates-tool) — the throw happens inside the returned validator closure, data-dependently. In Client.callTool (client.ts ~2449-2467) it is caught and converted to ProtocolError(InvalidParams, 'Failed to validate structured content: Unresolved $ref …'). The shared declaredDialect classifier guarantees both platforms pick the same dialect, but cannot make the cfworker engine's draft-7 keyword coverage complete. And the new engine-leg tests are Ajv-only — the validators.test.ts comment assumes cfworker dispatch differences are benign leniency ("applies both keyword sets in either draft mode"), which this shape disproves: it is a hard throw, not leniency. Notably, dependencies is exactly the keyword this PR deliberately supported in the legacyWrap rewriter — including a test for an entry keyed default with a $ref inside — yet that shape never ran through the cfworker engine.

Impact. A spec-valid draft-07 tool with spec-valid structuredContent works on a default Node client but fails intermittently (only on results where the dependency triggers) with InvalidParams on the byte-identical browser/Cloudflare Workers client. The same asymmetry applies server-side via fromJsonSchema input validation on Workers runtimes. This is a second engine divergence beyond the $ref-siblings one the PR documents — in the opposite direction (hard failure rather than Ajv-side strictness) — so the changeset's "one documented engine difference" framing and its "validates with draft-07 semantics on both … providers" claim are incomplete.

Why nit rather than blocking. Nothing that worked pre-PR regresses: before this change, any draft-07-declared schema was rejected pre-wire with the typed error on both platforms (100% failure), so the divergence is newly created surface, not a regression. The trigger intersection is narrow — schema-form dependencies + an entry key colliding with cfworker's ignored-keyword list + a $ref inside + a Workers/browser runtime + data that actually fires the dependency (zod-to-json-schema does not emit dependencies). And the root cause is an upstream engine limitation the SDK surfaces, not wrong SDK dispatch logic.

How to fix. Match the treatment the PR gave the $ref-siblings divergence: document it in the CfWorkerJsonSchemaValidator JSDoc and the migration guide's "known draft-07 engine difference" paragraph, and add a cfworker engine-leg pinning test (so a dependency bump that fixes or shifts the behavior is caught). Longer-term options: pre-normalize draft-07 dependencies into dependentSchemas/dependentRequired before constructing the cfworker Validator, or fix upstream by adding dependencies to schemaMapKeyword in @cfworker/json-schema's dereference.

/**
* Create a validator for the given JSON Schema
*
Expand All @@ -75,22 +78,7 @@
* @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);

Expand Down
Loading
Loading