fix(validators): honor declared draft-07/06 JSON Schema dialects instead of rejecting them (#2534)
This commit is contained in:
parent
6dac272880
commit
f130e1ac5f
12 changed files with 851 additions and 109 deletions
6
.changeset/validator-dialect-dispatch.md
Normal file
6
.changeset/validator-dialect-dispatch.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
@ -1455,9 +1454,19 @@ 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
|
||||
|
|
@ -1465,13 +1474,13 @@ declaring a different `$schema` are rejected with `Error("…unsupported dialect
|
|||
`$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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
|
||||
const engine = this.ajv;
|
||||
const engine = this._engineFor(schema);
|
||||
const ajvValidator =
|
||||
'$id' in schema && typeof schema.$id === 'string'
|
||||
? (engine.getSchema(schema.$id) ?? engine.compile(schema))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import { Validator } from '@cfworker/json-schema';
|
||||
|
||||
import { declaredDialect } from './dialects';
|
||||
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types';
|
||||
|
||||
/**
|
||||
|
|
@ -17,23 +18,23 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSche
|
|||
*/
|
||||
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`.
|
||||
*
|
||||
* Known draft-07 engine gap: `@cfworker/json-schema` does not treat draft-07 `dependencies` as
|
||||
* a name→subschema map during `$ref` collection, so a dependency entry whose KEY collides with
|
||||
* a JSON Schema keyword (`type`, `default`, `format`, `required`, `pattern`, …) and whose
|
||||
* subschema contains a `$ref` throws `Unresolved $ref` at validation time when the dependency
|
||||
* triggers — data-dependently, not at compile. The Node (classic Ajv) engine handles the same
|
||||
* schema correctly. Callers that hit this receive the SDK's typed validation error (the throw
|
||||
* is captured by the validation paths), and the deviation is pinned by a recorded-contract test.
|
||||
*
|
||||
* @example Use with default configuration (2020-12, shortcircuit on)
|
||||
* ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default"
|
||||
* const validator = new CfWorkerJsonSchemaValidator();
|
||||
|
|
@ -58,14 +59,24 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
|
|||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validator for the given JSON Schema
|
||||
*
|
||||
|
|
@ -75,22 +86,7 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
|
|||
* @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);
|
||||
|
||||
|
|
|
|||
61
packages/core-internal/src/validators/dialects.ts
Normal file
61
packages/core-internal/src/validators/dialects.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Declared-dialect classification shared by the default validator providers.
|
||||
*/
|
||||
|
||||
import type { JsonSchemaType } from './types';
|
||||
|
||||
/**
|
||||
* Canonical `$schema` URIs per supported dialect (http + https variants, trailing-`#` stripped).
|
||||
*/
|
||||
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'
|
||||
]);
|
||||
const DRAFT_2019_09_URIS: ReadonlySet<string> = new Set([
|
||||
'https://json-schema.org/draft/2019-09/schema',
|
||||
'http://json-schema.org/draft/2019-09/schema'
|
||||
]);
|
||||
const DRAFT_07_URIS: ReadonlySet<string> = new Set(['https://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema']);
|
||||
const DRAFT_06_URIS: ReadonlySet<string> = new Set(['https://json-schema.org/draft-06/schema', 'http://json-schema.org/draft-06/schema']);
|
||||
|
||||
/**
|
||||
* Dialects the default providers dispatch on. draft-06 maps to `'draft-7'`: draft-07 only adds
|
||||
* keywords over draft-06 (`if`/`then`/`else`), and enforcing them on a draft-06 schema is the
|
||||
* accepted downlevel.
|
||||
*/
|
||||
export type DeclaredDialect = '2020-12' | '2019-09' | 'draft-7';
|
||||
|
||||
/**
|
||||
* Whether a `$schema` value declares the 2019-09 dialect — the only supported dialect with
|
||||
* `$recursiveRef`/`$recursiveAnchor`. Non-throwing (unlike {@linkcode declaredDialect}) so
|
||||
* wire-layer callers can consult it for documents whose dialect may be unsupported.
|
||||
*/
|
||||
export function declares2019Dialect($schema: unknown): boolean {
|
||||
return typeof $schema === 'string' && DRAFT_2019_09_URIS.has($schema.replace(/#$/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a schema's declared `$schema` dialect. No `$schema` (or a non-string one) means
|
||||
* 2020-12. Any other dialect throws a plain `Error` with a clear message rather than letting the
|
||||
* engine crash on an opaque internal error or silently mis-validate; `remedy` names the calling
|
||||
* provider's escape hatch in that message.
|
||||
*/
|
||||
export function declaredDialect(schema: JsonSchemaType, remedy: string): DeclaredDialect {
|
||||
if (!('$schema' in schema) || typeof schema.$schema !== 'string') {
|
||||
return '2020-12';
|
||||
}
|
||||
const declared = schema.$schema.replace(/#$/, '');
|
||||
if (DRAFT_2020_12_URIS.has(declared)) {
|
||||
return '2020-12';
|
||||
}
|
||||
if (DRAFT_2019_09_URIS.has(declared)) {
|
||||
return '2019-09';
|
||||
}
|
||||
if (DRAFT_07_URIS.has(declared) || DRAFT_06_URIS.has(declared)) {
|
||||
return 'draft-7';
|
||||
}
|
||||
throw new Error(
|
||||
`JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). ` +
|
||||
`The default validator supports JSON Schema 2020-12, 2019-09, draft-07, and draft-06; ${remedy}`
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
* and never be re-derived in shared/ or server-side code.
|
||||
*/
|
||||
|
||||
import { declares2019Dialect } from '../../validators/dialects';
|
||||
|
||||
/**
|
||||
* Whether a JSON Schema's root is non-object: either an explicit non-object
|
||||
* `type`, or a typeless root such as `{anyOf:[…]}`. Object-shaped typeless
|
||||
|
|
@ -44,9 +46,63 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet<string> = new Set([
|
|||
'patternProperties',
|
||||
'$defs',
|
||||
'definitions',
|
||||
'dependentSchemas'
|
||||
'dependentSchemas',
|
||||
// draft-07's dependentSchemas predecessor; its array-of-strings form is
|
||||
// unaffected (string arrays contain no refs).
|
||||
'dependencies'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a subtree's `$id` establishes a new resolution base. A fragment-only
|
||||
* `$id` (`"#item"`, the draft-07/06 spelling of 2020-12's `$anchor`) does not
|
||||
* change the RFC 3986 base URI — same-document pointers inside still resolve
|
||||
* against the document root and must be rewritten.
|
||||
*/
|
||||
function establishesNewBase(id: unknown): boolean {
|
||||
return id !== undefined && !(typeof id === 'string' && id.startsWith('#'));
|
||||
}
|
||||
|
||||
/*
|
||||
* Reference/base-affecting keyword coverage across the four supported dialects
|
||||
* (2020-12, 2019-09, draft-07, draft-06). Every entry is rewritten, position-guarded,
|
||||
* or N/A with the reason; legacyWrap.test.ts pins each handled row:
|
||||
* - `$ref` (all dialects): JSON-Pointer forms `#`/`#/…` rewritten; other values untouched.
|
||||
* - `$dynamicRef` (2020-12): pointer forms rewritten like `$ref`; plain-name form (`#name`)
|
||||
* untouched — see `$anchor`.
|
||||
* - `$dynamicAnchor` (2020-12): N/A — location-independent plain name; the envelope adds no
|
||||
* anchors and the natural schema moves whole, so dynamic resolution is unchanged.
|
||||
* - `$anchor` (2019-09/2020-12): N/A — same as `$dynamicAnchor`; `#name` refs are fragments,
|
||||
* not pointers, and are never rewritten.
|
||||
* - `$recursiveRef` (2019-09): handled only in documents whose DECLARED dialect is 2019-09.
|
||||
* Elsewhere it is off-spec input copied verbatim — KNOWN LIMITATION: the engines already
|
||||
* disagree on it there (only classic Ajv ignores it; Ajv2020 and @cfworker enforce it in
|
||||
* every draft mode), so no consistent wrap behavior exists — the verbatim `#` mis-resolves
|
||||
* to the envelope on the enforcing legs, while converting would manufacture a constraint
|
||||
* on the classic-Ajv leg. Inherent to envelope relocation plus engine disagreement; the
|
||||
* escape is the 2026 era or authoring the schema with a proper 2019-09 stamp. Within
|
||||
* 2019-09: value restricted to `#`, and per core §8.2.4.2.1 a root-base ref is dynamic
|
||||
* only when the DOCUMENT ROOT carries `$recursiveAnchor: true` (a non-root anchor can
|
||||
* never be the initial target and is inert). Root-anchor-less documents: converted to the
|
||||
* statically equivalent `$ref: '#/properties/result'`; when `$ref` co-occurs (legal,
|
||||
* conjunctive in 2019-09) the conversion joins as an `allOf` entry instead. Documents
|
||||
* whose root carries `$recursiveAnchor: true`: left verbatim — KNOWN LIMITATION:
|
||||
* relocation cannot preserve dynamic re-resolution (a static rewrite would freeze it and
|
||||
* the envelope root carries no anchor), so root-anchored recursion still mis-resolves on
|
||||
* the 2025 projection.
|
||||
* - `$recursiveAnchor` (2019-09): boolean, not a location — only the ROOT occurrence gates the
|
||||
* conversion above; nested occurrences are copied verbatim and stay inert.
|
||||
* - `$id`, URI form (all dialects): establishes a new base — subtree skipped (root and nested).
|
||||
* - `$id`, fragment form (draft-07/06 anchor spelling; illegal in 2019-09/2020-12): does not
|
||||
* change the base — descended into.
|
||||
* - `$schema`: hoisted to the wrapper root so dialect dispatch and the graceful
|
||||
* unsupported-dialect rejection see it.
|
||||
* - `$vocabulary` (2019-09/2020-12): N/A — meta-schema-only keyword, inert in tool schemas.
|
||||
* - Name→subschema maps (`properties`, `patternProperties`, `$defs`, `definitions`,
|
||||
* `dependentSchemas`, draft-07/06 `dependencies`): entries are name positions; their keys
|
||||
* are never treated as keywords.
|
||||
* - Data-position keywords (`const`/`enum`/`default`/`examples`): values are instance data,
|
||||
* never descended into.
|
||||
*/
|
||||
/**
|
||||
* Wrap a non-object output schema in the 2025-era envelope:
|
||||
* `{type:'object', properties:{result:<natural>}, required:['result']}`.
|
||||
|
|
@ -60,33 +116,47 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet<string> = new Set([
|
|||
* The rewrite is position-aware: data-valued keywords
|
||||
* (`const`/`enum`/`default`/`examples`) in keyword position are NOT descended
|
||||
* into; the same names appearing as property names under
|
||||
* `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas`
|
||||
* ARE descended into (they're subschemas). The rewrite is also `$id`-scoped:
|
||||
* if the natural root carries `$id` no pointer is rewritten (same-document
|
||||
* refs inside resolve against the embedded `$id` base, not the wrapper root),
|
||||
* and any subtree that establishes its own `$id` is left untouched for the
|
||||
* same reason.
|
||||
* `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas`/
|
||||
* `dependencies` ARE descended into (they're subschemas). The rewrite is also
|
||||
* `$id`-scoped: if the natural root carries a base-establishing `$id` no
|
||||
* pointer is rewritten (same-document refs inside resolve against the embedded
|
||||
* `$id` base, not the wrapper root), and any subtree that establishes its own
|
||||
* `$id` is left untouched for the same reason. Fragment-only `$id` (`"#item"`,
|
||||
* draft-07's anchor spelling) does not establish a base and IS descended into.
|
||||
*/
|
||||
export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unknown>>): Record<string, unknown> {
|
||||
// A root `$schema` is hoisted to the wrapper root: it's a document-level
|
||||
// dialect declaration and the SEP-1613 dialect checks (both built-in
|
||||
// providers) only inspect the root, so leaving it under `properties.result`
|
||||
// would make a non-2020-12 schema pass the dialect check on the 2025
|
||||
// projection while the same tool is rejected on the 2026 era.
|
||||
// dialect declaration and the built-in providers' dialect dispatch only
|
||||
// inspects the root, so leaving it under `properties.result` would make
|
||||
// the wrapper compile under the default 2020-12 engine (an opaque Ajv2020
|
||||
// compile error on draft-07 tuple-form `items` instead of the classic
|
||||
// engine's tuple semantics) while the same tool dispatches to the declared
|
||||
// dialect on the 2026 era — and hide an unsupported dialect from the
|
||||
// graceful rejection.
|
||||
const $schema = typeof natural['$schema'] === 'string' ? natural['$schema'] : undefined;
|
||||
// `$id` at the natural root: every same-document `#/…` ref inside resolves
|
||||
// against that base URI, not against the wrapper root — skip the rewrite.
|
||||
if (natural['$id'] !== undefined) {
|
||||
// A base-establishing `$id` at the natural root: every same-document `#/…`
|
||||
// ref inside resolves against that base URI, not against the wrapper root —
|
||||
// skip the rewrite. Fragment-only `$id` keeps the document-root base.
|
||||
if (establishesNewBase(natural['$id'])) {
|
||||
return { ...($schema !== undefined && { $schema }), type: 'object', properties: { result: natural }, required: ['result'] };
|
||||
}
|
||||
// `$recursiveRef` is a 2019-09-only keyword: the conversion applies solely to documents
|
||||
// whose DECLARED dialect is 2019-09 (one document-level check). Elsewhere it is copied
|
||||
// verbatim — converting would manufacture a constraint on the classic-Ajv leg, which
|
||||
// ignores the member; the legs that enforce it are a KNOWN LIMITATION (coverage block).
|
||||
// Within 2019-09, a root-base `$recursiveRef: '#'` is dynamic only when the DOCUMENT
|
||||
// ROOT carries `$recursiveAnchor: true` (core §8.2.4.2.1); otherwise it is statically
|
||||
// `$ref: '#'`-equivalent and is converted. See the coverage block above.
|
||||
const convertRecursiveRefs = declares2019Dialect(natural['$schema']) && natural['$recursiveAnchor'] !== true;
|
||||
const rewriteRefs = (node: unknown, parentIsNameMap: boolean): unknown => {
|
||||
if (Array.isArray(node)) return node.map(item => rewriteRefs(item, false));
|
||||
if (node === null || typeof node !== 'object') return node;
|
||||
// A nested `$id` establishes its own resolution base for the subtree —
|
||||
// A nested base-establishing `$id` owns resolution for the subtree —
|
||||
// same-document refs inside are no longer relative to the wrapper root.
|
||||
// Only applies in keyword position (a property NAMED `$id` is just a name).
|
||||
if (!parentIsNameMap && (node as Record<string, unknown>)['$id'] !== undefined) return node;
|
||||
if (!parentIsNameMap && establishesNewBase((node as Record<string, unknown>)['$id'])) return node;
|
||||
const out: Record<string, unknown> = {};
|
||||
let convertedRecursion = false;
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if (parentIsNameMap) {
|
||||
// Name position: `k` is an author-chosen property/def name, `v` is a
|
||||
|
|
@ -94,6 +164,10 @@ export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unkno
|
|||
out[k] = rewriteRefs(v, false);
|
||||
} else if ((k === '$ref' || k === '$dynamicRef') && typeof v === 'string') {
|
||||
out[k] = v === '#' ? '#/properties/result' : v.startsWith('#/') ? `#/properties/result${v.slice(1)}` : v;
|
||||
} else if (k === '$recursiveRef' && v === '#' && convertRecursiveRefs) {
|
||||
// Emitted after the loop: plain `$ref` when free, `allOf` join when `$ref`
|
||||
// co-occurs ($ref and $recursiveRef are conjunctive in-place applicators).
|
||||
convertedRecursion = true;
|
||||
} else if (REF_REWRITE_DATA_POSITION_KEYS.has(k)) {
|
||||
out[k] = v;
|
||||
} else if (REF_REWRITE_NAME_MAP_KEYS.has(k)) {
|
||||
|
|
@ -102,6 +176,13 @@ export function wrapOutputSchemaForLegacy(natural: Readonly<Record<string, unkno
|
|||
out[k] = rewriteRefs(v, false);
|
||||
}
|
||||
}
|
||||
if (convertedRecursion) {
|
||||
if ('$ref' in out) {
|
||||
out['allOf'] = [...(Array.isArray(out['allOf']) ? out['allOf'] : []), { $ref: '#/properties/result' }];
|
||||
} else {
|
||||
out['$ref'] = '#/properties/result';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -46,12 +46,29 @@ describe('AjvJsonSchemaValidator lazy engine construction', () => {
|
|||
expect(engineSlot(provider)).toBe(fake);
|
||||
});
|
||||
|
||||
it('still rejects non-2020-12 $schema dialects before constructing the default engine', () => {
|
||||
it('still rejects unknown $schema dialects before constructing the default engine', () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' })).toThrow(
|
||||
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-04/schema#', type: 'object' })).toThrow(
|
||||
/unsupported dialect/
|
||||
);
|
||||
// The dialect check fires before engine construction — no engine was built for the rejected schema.
|
||||
expect(engineSlot(provider)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a draft-07 schema builds only the draft-07 engine, not the 2020-12 one', () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
const validate = provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' });
|
||||
expect(validate({}).valid).toBe(true);
|
||||
expect(engineSlot(provider)).toBeUndefined();
|
||||
expect((provider as unknown as { _ajvDraft7: unknown })._ajvDraft7).toBeDefined();
|
||||
});
|
||||
|
||||
it('a 2019-09 schema builds only the 2019-09 engine', () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
const validate = provider.getValidator({ $schema: 'https://json-schema.org/draft/2019-09/schema', type: 'object' });
|
||||
expect(validate({}).valid).toBe(true);
|
||||
expect(engineSlot(provider)).toBeUndefined();
|
||||
expect((provider as unknown as { _ajvDraft7: unknown })._ajvDraft7).toBeUndefined();
|
||||
expect((provider as unknown as { _ajv2019: unknown })._ajv2019).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { vi } from 'vitest';
|
|||
|
||||
import { Ajv, AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
|
||||
import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider';
|
||||
import { declaredDialect } from '../../src/validators/dialects';
|
||||
import type { JsonSchemaType } from '../../src/validators/types';
|
||||
|
||||
// Test with both AJV and CfWorker validators
|
||||
|
|
@ -625,15 +626,17 @@ describe('Missing dependencies', () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* SEP-1613 declares JSON Schema 2020-12 the dialect for tool schemas. The built-in providers
|
||||
* validate as 2020-12 only: a schema with no `$schema` (or `$schema: …2020-12…`) compiles; a
|
||||
* schema declaring any other `$schema` is rejected with a clear `Error`. The escape hatch is
|
||||
* the existing custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`).
|
||||
* The spec honors a schema's declared `$schema` dialect (absent means 2020-12 per SEP-1613).
|
||||
* The built-in providers dispatch: no `$schema` / 2020-12 → the 2020-12 engine; draft-07 /
|
||||
* draft-06 → a draft-07 engine (draft-07's changes over draft-06 are additive). Any other
|
||||
* declared dialect is rejected with a clear `Error`. The escape hatch is the existing
|
||||
* custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`).
|
||||
*
|
||||
* Discriminator: `prefixItems` is a 2020-12 keyword that the draft-07 Ajv class silently
|
||||
* ignores under `strict:false`, so it proves the default engine is `Ajv2020`.
|
||||
* Discriminators: `prefixItems` is a 2020-12 keyword the draft-07 engines silently ignore
|
||||
* under lenient options, and the positional `items` array is draft-07's tuple form —
|
||||
* together they prove which engine ran, not merely that compile stopped throwing.
|
||||
*/
|
||||
describe('SEP-1613 $schema dialect handling (2020-12 only)', () => {
|
||||
describe('$schema dialect dispatch', () => {
|
||||
const DRAFT_07_URI = 'http://json-schema.org/draft-07/schema#';
|
||||
const DRAFT_2020_URI = 'https://json-schema.org/draft/2020-12/schema';
|
||||
const prefixItemsSchema = ($schema?: string): JsonSchemaType => ({
|
||||
|
|
@ -643,6 +646,13 @@ describe('SEP-1613 $schema dialect handling (2020-12 only)', () => {
|
|||
});
|
||||
/** Violates `prefixItems` (positions swapped). */
|
||||
const PREFIX_ITEMS_BAD: unknown = ['x', 1];
|
||||
/** Draft-07 tuple form (positional `items` array — not representable in the 2020-12-shaped type). */
|
||||
const tupleItemsSchema = ($schema: string): JsonSchemaType =>
|
||||
({
|
||||
$schema,
|
||||
type: 'array',
|
||||
items: [{ type: 'number' }, { type: 'string' }]
|
||||
}) as unknown as JsonSchemaType;
|
||||
|
||||
describe.each(validators)('$name', ({ provider }) => {
|
||||
it('default → Ajv2020 / 2020-12 (prefixItems is enforced)', () => {
|
||||
|
|
@ -656,17 +666,143 @@ describe('SEP-1613 $schema dialect handling (2020-12 only)', () => {
|
|||
expect(v(PREFIX_ITEMS_BAD).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('$schema: draft-07 → graceful Error', () => {
|
||||
expect(() => provider.getValidator(prefixItemsSchema(DRAFT_07_URI))).toThrow(/unsupported dialect.*2020-12 only/);
|
||||
it.each([
|
||||
['draft-07 http, trailing #', 'http://json-schema.org/draft-07/schema#'],
|
||||
['draft-07 https, no #', 'https://json-schema.org/draft-07/schema'],
|
||||
['draft-06 http', 'http://json-schema.org/draft-06/schema#'],
|
||||
['draft-06 https', 'https://json-schema.org/draft-06/schema'],
|
||||
['2019-09 https, trailing #', 'https://json-schema.org/draft/2019-09/schema#'],
|
||||
['2019-09 http', 'http://json-schema.org/draft/2019-09/schema']
|
||||
])('$schema %s → declared-dialect tuple semantics on `items`', (_label, uri) => {
|
||||
const v = provider.getValidator(tupleItemsSchema(uri));
|
||||
expect(v([1, 'x']).valid).toBe(true);
|
||||
expect(v(['x', 1]).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('$schema: 2019-09 → graceful Error', () => {
|
||||
expect(() => provider.getValidator(prefixItemsSchema('https://json-schema.org/draft/2019-09/schema'))).toThrow(
|
||||
/unsupported dialect/
|
||||
it.each([
|
||||
['draft-04', 'http://json-schema.org/draft-04/schema#'],
|
||||
['version-less alias', 'http://json-schema.org/schema#'],
|
||||
['garbage', 'https://example.com/my-dialect']
|
||||
])('$schema %s → graceful Error listing supported dialects', (_label, uri) => {
|
||||
expect(() => provider.getValidator(prefixItemsSchema(uri))).toThrow(
|
||||
/unsupported dialect.*2020-12, 2019-09, draft-07, and draft-06/s
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The shared classifier is what both providers dispatch on — pinning it directly covers
|
||||
// the CfWorker side, whose engine applies both keyword sets in either draft mode (so the
|
||||
// dispatch is not observable through validation results there).
|
||||
describe('declaredDialect classifier', () => {
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['https', 'https://json-schema.org/draft/2020-12/schema'],
|
||||
['http, trailing #', 'http://json-schema.org/draft/2020-12/schema#']
|
||||
])('%s → 2020-12', (_label, uri) => {
|
||||
expect(declaredDialect({ ...(uri ? { $schema: uri } : {}), type: 'object' }, 'r')).toBe('2020-12');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https, trailing #', 'https://json-schema.org/draft/2019-09/schema#'],
|
||||
['http', 'http://json-schema.org/draft/2019-09/schema']
|
||||
])('2019-09 %s → 2019-09', (_label, uri) => {
|
||||
expect(declaredDialect({ $schema: uri, type: 'object' }, 'r')).toBe('2019-09');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['draft-07 http #', 'http://json-schema.org/draft-07/schema#'],
|
||||
['draft-07 https', 'https://json-schema.org/draft-07/schema'],
|
||||
['draft-06 http #', 'http://json-schema.org/draft-06/schema#'],
|
||||
['draft-06 https', 'https://json-schema.org/draft-06/schema']
|
||||
])('%s → draft-7', (_label, uri) => {
|
||||
expect(declaredDialect({ $schema: uri, type: 'object' }, 'r')).toBe('draft-7');
|
||||
});
|
||||
|
||||
it('unknown → throws with the remedy appended', () => {
|
||||
expect(() => declaredDialect({ $schema: 'https://example.com/dialect', type: 'object' }, 'REMEDY.')).toThrow(
|
||||
/unsupported dialect.*2020-12, 2019-09, draft-07, and draft-06; REMEDY\.$/s
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('AJV: declared 2019-09 selects Ajv2019 (unevaluatedProperties enforced, tuple items compiles)', () => {
|
||||
// Pins the engine against both wrong-engine mutations: Ajv2020 would reject the
|
||||
// `items` array form at compile, and classic Ajv ignores `unevaluatedProperties`.
|
||||
// (No CfWorker leg: @cfworker/json-schema applies both keyword sets in every draft mode.)
|
||||
const v = new AjvJsonSchemaValidator().getValidator({
|
||||
$schema: 'https://json-schema.org/draft/2019-09/schema',
|
||||
type: 'object',
|
||||
properties: { pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] } },
|
||||
unevaluatedProperties: false
|
||||
} as unknown as JsonSchemaType);
|
||||
expect(v({ pair: [1, 'x'] }).valid).toBe(true);
|
||||
expect(v({ pair: ['x', 1] }).valid).toBe(false); // tuple semantics live
|
||||
expect(v({ pair: [1, 'x'], extra: 1 }).valid).toBe(false); // 2019-09 keyword enforced
|
||||
});
|
||||
|
||||
it('recorded contract: cfworker throws on $ref inside a keyword-named draft-07 `dependencies` entry', () => {
|
||||
// @cfworker/json-schema does not treat draft-07 `dependencies` as a name→subschema map
|
||||
// during ref collection: an entry keyed like a keyword (`type` here) with a `$ref`
|
||||
// inside is skipped, and validation THROWS `Unresolved $ref` when the dependency
|
||||
// triggers — data-dependently (compile succeeds; non-triggering data validates).
|
||||
// Through Client.callTool this surfaces as the SDK's TYPED validation error
|
||||
// (ProtocolError InvalidParams "Failed to validate structured content: …" — the
|
||||
// structuredContent validation catch wraps any engine throw), never as an unhandled
|
||||
// exception. The Node engine (classic Ajv) handles the same schema correctly. This
|
||||
// test records that difference so a dependency bump that changes either side is caught.
|
||||
const schema: JsonSchemaType = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
definitions: { addr: { type: 'string' } },
|
||||
properties: { type: { type: 'string' }, billing: {} },
|
||||
dependencies: { type: { properties: { billing: { $ref: '#/definitions/addr' } } } }
|
||||
} as unknown as JsonSchemaType;
|
||||
const triggering = { type: 'card', billing: 'x' };
|
||||
|
||||
const cf = new CfWorkerJsonSchemaValidator().getValidator(schema);
|
||||
expect(cf({ billing: 'x' }).valid).toBe(true); // dependency not triggered
|
||||
expect(() => cf(triggering)).toThrow(/Unresolved \$ref/);
|
||||
|
||||
const ajv = new AjvJsonSchemaValidator().getValidator(schema);
|
||||
expect(ajv(triggering).valid).toBe(true);
|
||||
expect(ajv({ type: 'card', billing: 5 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('recorded contract: the engines DIVERGE on $ref siblings under draft-07', () => {
|
||||
// Draft-07 says keywords adjacent to $ref MUST be ignored. Classic Ajv (v8)
|
||||
// evaluates them anyway — non-configurable, and identical to v1's default
|
||||
// engine — while @cfworker follows the spec. This test records that known
|
||||
// difference so a dependency bump that changes either side is caught.
|
||||
const schema: JsonSchemaType = {
|
||||
$schema: DRAFT_07_URI,
|
||||
type: 'object',
|
||||
definitions: { name: { type: 'string' } },
|
||||
properties: { a: { $ref: '#/definitions/name', maxLength: 2 } },
|
||||
required: ['a']
|
||||
} as unknown as JsonSchemaType;
|
||||
const data = { a: 'hello' };
|
||||
|
||||
// Node engine: maxLength next to $ref is enforced → rejected (stricter than spec).
|
||||
expect(new AjvJsonSchemaValidator().getValidator(schema)(data).valid).toBe(false);
|
||||
// cfworker engine: sibling ignored per draft-07 → accepted.
|
||||
expect(new CfWorkerJsonSchemaValidator().getValidator(schema)(data).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('AJV: declared draft-07 selects the draft-07 ENGINE (prefixItems ignored, items enforced)', () => {
|
||||
// Contradictory keywords: under the classic engine the `items` tuple wins and
|
||||
// `prefixItems` is unknown; `Ajv2020` would enforce `prefixItems` and reject the `items`
|
||||
// array form at compile — so [1,'x'] passing pins the engine. (No CfWorker leg:
|
||||
// @cfworker/json-schema applies both keyword sets in either draft mode.)
|
||||
const v = new AjvJsonSchemaValidator().getValidator({
|
||||
$schema: DRAFT_07_URI,
|
||||
type: 'array',
|
||||
items: [{ type: 'number' }, { type: 'string' }],
|
||||
prefixItems: [{ type: 'string' }, { type: 'number' }]
|
||||
} as unknown as JsonSchemaType);
|
||||
expect(v([1, 'x']).valid).toBe(true);
|
||||
expect(v(['x', 1]).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('AJV: custom Ajv instance bypasses the $schema check (caller owns dialect)', () => {
|
||||
// A draft-07 Ajv passed explicitly: even with `$schema: draft-07`, the provider does not
|
||||
// throw — and `prefixItems` is unknown to draft-07 Ajv and silently ignored.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { JsonSchemaType } from '../../src/validators/types';
|
||||
import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
|
||||
import { wrapOutputSchemaForLegacy } from '../../src/wire/rev2025-11-25/legacyWrap';
|
||||
import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec';
|
||||
|
||||
|
|
@ -156,3 +158,235 @@ describe('rev2025Codec.projectCallToolResult: value-shape wrap', () => {
|
|||
expect(out.structuredContent).toEqual({ result: { a: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapOutputSchemaForLegacy: draft-07 idioms (declared-dialect schemas flow since the validators dispatch on $schema)', () => {
|
||||
const DRAFT_07 = 'http://json-schema.org/draft-07/schema#';
|
||||
|
||||
it('`dependencies` is a name→subschema map: entries keyed like data keywords (default/…) are still rewritten', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: DRAFT_07,
|
||||
anyOf: [{ type: 'object', dependencies: { default: { $ref: '#/definitions/rule' }, other: { $ref: '#/definitions/rule' } } }],
|
||||
definitions: { rule: { type: 'string' } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'default')).toEqual({
|
||||
$ref: '#/properties/result/definitions/rule'
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'other')).toEqual({
|
||||
$ref: '#/properties/result/definitions/rule'
|
||||
});
|
||||
});
|
||||
|
||||
it('a `dependencies` entry keyed `$id` is a name position — it does not suppress rewriting of the map', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: DRAFT_07,
|
||||
anyOf: [{ type: 'object', dependencies: { $id: { $ref: '#/definitions/rule' }, other: { $ref: '#/definitions/rule' } } }],
|
||||
definitions: { rule: { type: 'string' } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', '$id')).toEqual({
|
||||
$ref: '#/properties/result/definitions/rule'
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'other')).toEqual({
|
||||
$ref: '#/properties/result/definitions/rule'
|
||||
});
|
||||
});
|
||||
|
||||
it('draft-07 array-of-strings `dependencies` entries pass through untouched', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: DRAFT_07,
|
||||
anyOf: [{ type: 'object', dependencies: { a: ['b', 'c'] } }]
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'anyOf', 0, 'dependencies', 'a')).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('a fragment-only nested $id (draft-07 anchor spelling) does not establish a new base — inner refs are rewritten', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: DRAFT_07,
|
||||
type: 'array',
|
||||
items: { $id: '#item', type: 'object', properties: { node: { $ref: '#/definitions/Node' } } },
|
||||
definitions: { Node: { type: 'number' } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', '$id')).toBe('#item');
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'node')).toEqual({
|
||||
$ref: '#/properties/result/definitions/Node'
|
||||
});
|
||||
});
|
||||
|
||||
it('a fragment-only ROOT $id does not suppress the rewrite either', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: DRAFT_07,
|
||||
$id: '#root',
|
||||
type: 'array',
|
||||
items: { $ref: '#/definitions/Node' },
|
||||
definitions: { Node: { type: 'number' } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items')).toEqual({ $ref: '#/properties/result/definitions/Node' });
|
||||
});
|
||||
|
||||
it('a URI-valued $id still suppresses the rewrite (nested and root)', () => {
|
||||
const nested = wrapOutputSchemaForLegacy({
|
||||
type: 'array',
|
||||
items: { $id: 'https://example.com/item', properties: { node: { $ref: '#/definitions/Node' } } },
|
||||
definitions: { Node: { type: 'number' } }
|
||||
});
|
||||
expect(dig(nested, 'properties', 'result', 'items', 'properties', 'node')).toEqual({ $ref: '#/definitions/Node' });
|
||||
|
||||
const root = wrapOutputSchemaForLegacy({
|
||||
$id: 'https://example.com/root',
|
||||
type: 'array',
|
||||
items: { $ref: '#/definitions/Node' },
|
||||
definitions: { Node: { type: 'number' } }
|
||||
});
|
||||
expect(dig(root, 'properties', 'result', 'items')).toEqual({ $ref: '#/definitions/Node' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapOutputSchemaForLegacy: 2019-09 recursion ($recursiveRef/$recursiveAnchor)', () => {
|
||||
const URI_2019 = 'https://json-schema.org/draft/2019-09/schema';
|
||||
|
||||
it('anchor-less $recursiveRef:"#" is converted to a static $ref at the relocated root', () => {
|
||||
// 2019-09 restricts $recursiveRef to "#"; with no $recursiveAnchor in the document
|
||||
// it is equivalent to $ref:"#", so the wrap converts it to the rewritten pointer.
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $ref: '#/properties/result' });
|
||||
});
|
||||
|
||||
it('converted recursion actually validates recursive values (engine leg)', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }
|
||||
});
|
||||
const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType);
|
||||
expect(v({ result: [1, [2, 3]] }).valid).toBe(true);
|
||||
expect(v({ result: [1, 'x'] }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('with a $recursiveAnchor in the document, $recursiveRef is left verbatim (documented limitation)', () => {
|
||||
// Dynamic re-resolution cannot be preserved under relocation: a static rewrite would
|
||||
// freeze the ref, and the envelope root carries no anchor. Left as authored.
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
$recursiveAnchor: true,
|
||||
type: 'array',
|
||||
items: { $recursiveRef: '#' }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items')).toEqual({ $recursiveRef: '#' });
|
||||
expect(dig(wrapped, 'properties', 'result', '$recursiveAnchor')).toBe(true);
|
||||
});
|
||||
|
||||
it('properties NAMED $recursiveRef/$recursiveAnchor are name positions — no conversion, no anchor detection', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
// a property literally named $recursiveAnchor (boolean-schema `true`) must
|
||||
// not suppress conversion...
|
||||
properties: { $recursiveAnchor: true, $recursiveRef: { type: 'string' } },
|
||||
anyOf: [{ $recursiveRef: '#' }, { type: 'number' }]
|
||||
}
|
||||
});
|
||||
// ...and the keyword-position occurrence still converts.
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 0)).toEqual({ $ref: '#/properties/result' });
|
||||
// Name-position entries are untouched.
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'properties', '$recursiveRef')).toEqual({ type: 'string' });
|
||||
});
|
||||
|
||||
it('plain-name anchor refs ("#name") are never rewritten; patternProperties/dependentSchemas are name maps', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
patternProperties: { '^d': { $ref: '#/$defs/D' } },
|
||||
dependentSchemas: { default: { $ref: '#/$defs/D' } },
|
||||
properties: { a: { $ref: '#node' } }
|
||||
},
|
||||
$defs: { D: { type: 'string' }, N: { $anchor: 'node', type: 'number' } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'patternProperties', '^d')).toEqual({ $ref: '#/properties/result/$defs/D' });
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'dependentSchemas', 'default')).toEqual({
|
||||
$ref: '#/properties/result/$defs/D'
|
||||
});
|
||||
// "#node" is a location-independent plain-name anchor fragment, not a JSON Pointer.
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'a')).toEqual({ $ref: '#node' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapOutputSchemaForLegacy: $recursiveRef gate precision (2019-09 core §8.2.4.2.1)', () => {
|
||||
const URI_2019 = 'https://json-schema.org/draft/2019-09/schema';
|
||||
|
||||
it('a NON-ROOT $recursiveAnchor is inert for a root-base ref — conversion still applies', () => {
|
||||
// Only a root $recursiveAnchor makes a root-base $recursiveRef dynamic; an anchor
|
||||
// under $defs can never be the initial target and must not suppress conversion.
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] },
|
||||
$defs: { unused: { $recursiveAnchor: true, type: 'string' } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $ref: '#/properties/result' });
|
||||
|
||||
const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType);
|
||||
expect(v({ result: [1, [2, 3]] }).valid).toBe(true);
|
||||
expect(v({ result: [1, 'x'] }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('$ref and $recursiveRef co-occur conjunctively — conversion joins via allOf, both enforced', () => {
|
||||
// 2019-09 in-place applicators: both $ref and $recursiveRef apply to the same object.
|
||||
// The node keeps its (rewritten) $ref; the recursion converts into an allOf entry.
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: { $ref: '#/$defs/notHuge', $recursiveRef: '#' },
|
||||
$defs: { notHuge: { maxItems: 3 } }
|
||||
});
|
||||
const items = dig(wrapped, 'properties', 'result', 'items') as Record<string, unknown>;
|
||||
expect(items['$ref']).toBe('#/properties/result/$defs/notHuge');
|
||||
expect(items['$recursiveRef']).toBeUndefined();
|
||||
expect(items['allOf']).toEqual([{ $ref: '#/properties/result' }]);
|
||||
|
||||
const v = new AjvJsonSchemaValidator().getValidator(wrapped as JsonSchemaType);
|
||||
expect(v({ result: [[], [[]]] }).valid).toBe(true); // nested arrays, all ≤3 items
|
||||
expect(v({ result: [[[], [], [], []]] }).valid).toBe(false); // violates $ref (maxItems)
|
||||
expect(v({ result: [['x']] }).valid).toBe(false); // violates recursion (inner non-array)
|
||||
});
|
||||
|
||||
it('co-occurrence with an existing allOf appends, not clobbers', () => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
$schema: URI_2019,
|
||||
type: 'array',
|
||||
items: { $ref: '#/$defs/a', $recursiveRef: '#', allOf: [{ $ref: '#/$defs/b' }] },
|
||||
$defs: { a: { minItems: 0 }, b: { maxItems: 3 } }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'allOf')).toEqual([
|
||||
{ $ref: '#/properties/result/$defs/b' },
|
||||
{ $ref: '#/properties/result' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapOutputSchemaForLegacy: $recursiveRef conversion is scoped to 2019-09-declared documents', () => {
|
||||
// $recursiveRef is a 2019-09-only keyword: outside a 2019-09 stamp the wrap copies it
|
||||
// verbatim — converting would manufacture a constraint on the classic-Ajv leg, which
|
||||
// ignores the member (Ajv2020 and @cfworker enforce it in every mode; a KNOWN
|
||||
// LIMITATION, see legacyWrap.ts). These rows pin the WRAP contract only, deliberately
|
||||
// without an engine leg: off-spec input the engines disagree on has no consistent
|
||||
// validation outcome to assert (an engine leg would fail on the three enforcing legs).
|
||||
it.each([
|
||||
['2020-12', 'https://json-schema.org/draft/2020-12/schema'],
|
||||
['draft-07', 'http://json-schema.org/draft-07/schema#'],
|
||||
['absent', undefined]
|
||||
])('%s document: $recursiveRef member passes through verbatim', (_label, uri) => {
|
||||
const wrapped = wrapOutputSchemaForLegacy({
|
||||
...(uri ? { $schema: uri } : {}),
|
||||
type: 'array',
|
||||
items: { anyOf: [{ type: 'number' }, { $recursiveRef: '#' }] }
|
||||
});
|
||||
expect(dig(wrapped, 'properties', 'result', 'items', 'anyOf', 1)).toEqual({ $recursiveRef: '#' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
|
|||
'client:jsonschema:unsupported-dialect-graceful': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect … 2020-12 only" message instead of having the underlying engine fail opaquely.'
|
||||
'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise (2020-12, 2019-09, draft-07, and draft-06 are supported) is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect" message instead of having the underlying engine fail opaquely.'
|
||||
},
|
||||
'client:jsonschema:bad-schema-isolates-tool': {
|
||||
source: 'sdk',
|
||||
|
|
|
|||
164
test/integration/test/client/outputSchemaDialect.test.ts
Normal file
164
test/integration/test/client/outputSchemaDialect.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Ecosystem servers advertise tool schemas stamped with a draft-07 `$schema`
|
||||
* (zod-to-json-schema's default output — e.g. the official Filesystem server).
|
||||
* The spec honors the declared dialect (absent means 2020-12), so the default
|
||||
* validator must dispatch on it instead of rejecting pre-wire with InvalidParams.
|
||||
* Unknown dialects still produce the typed error.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { JsonSchemaType } from '@modelcontextprotocol/core-internal';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
|
||||
import { fromJsonSchema, McpServer, Server } from '@modelcontextprotocol/server';
|
||||
|
||||
/** zod-to-json-schema default output shape, lifted from the official Filesystem server. */
|
||||
const FILESYSTEM_STYLE_SCHEMA = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: { type: 'string' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'] }
|
||||
},
|
||||
required: ['content'],
|
||||
additionalProperties: false
|
||||
} as const;
|
||||
|
||||
/** Draft-07 tuple form: positional `items` array (2020-12 moved this to `prefixItems`). */
|
||||
const DRAFT_07_TUPLE_SCHEMA = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
pair: { type: 'array', items: [{ type: 'number' }, { type: 'string' }] }
|
||||
},
|
||||
required: ['pair']
|
||||
} as const;
|
||||
|
||||
/** zod-to-json-schema `target: 'openAi'` output shape (also its `target: '2019-09'`). */
|
||||
const OPENAI_TARGET_SCHEMA = {
|
||||
$schema: 'https://json-schema.org/draft/2019-09/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
score: { type: 'number' }
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A real low-level Server advertising a verbatim outputSchema without compiling it —
|
||||
* the shape of a non-SDK ecosystem server. `structuredContent` comes from `results`
|
||||
* keyed by tool name.
|
||||
*/
|
||||
async function connectPair(
|
||||
outputSchema: unknown,
|
||||
structuredContent: () => unknown
|
||||
): Promise<{ client: Client; close: () => Promise<unknown> }> {
|
||||
const server = new Server({ name: 'ecosystem-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [{ name: 'read_text_file', inputSchema: { type: 'object' }, outputSchema: outputSchema as JsonSchemaType }]
|
||||
}));
|
||||
server.setRequestHandler('tools/call', async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
structuredContent: structuredContent() as Record<string, unknown>
|
||||
}));
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
// Populate the tools/list cache — output validation derives from the cached entry.
|
||||
await client.listTools();
|
||||
return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) };
|
||||
}
|
||||
|
||||
describe('declared-dialect tool schemas end to end', () => {
|
||||
test('draft-07 outputSchema (Filesystem-server shape) validates instead of failing pre-wire', async () => {
|
||||
const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ content: 'hello', encoding: 'utf8' }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({
|
||||
structuredContent: { content: 'hello', encoding: 'utf8' }
|
||||
});
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a VIOLATING result against a draft-07 outputSchema still fails validation', async () => {
|
||||
// `content` missing — the draft-07 engine must actually run, not pass through.
|
||||
const { client, close } = await connectPair(FILESYSTEM_STYLE_SCHEMA, () => ({ encoding: 'utf8' }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('draft-07 tuple `items` gets draft-07 positional semantics', async () => {
|
||||
let pair: unknown = [1, 'x'];
|
||||
const { client, close } = await connectPair(DRAFT_07_TUPLE_SCHEMA, () => ({ pair }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({ structuredContent: { pair: [1, 'x'] } });
|
||||
|
||||
pair = ['x', 1]; // violates the positional item schemas
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('2019-09 outputSchema (zod-to-json-schema openAi target shape) validates instead of failing pre-wire', async () => {
|
||||
const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ summary: 'ok', score: 1 }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).resolves.toMatchObject({
|
||||
structuredContent: { summary: 'ok', score: 1 }
|
||||
});
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a VIOLATING result against a 2019-09 outputSchema still fails validation', async () => {
|
||||
// `summary` missing — the 2019-09 engine must actually run, not pass through.
|
||||
const { client, close } = await connectPair(OPENAI_TARGET_SCHEMA, () => ({ score: 1 }));
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/does not match the tool's output schema/);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('unknown dialect still fails pre-wire with the typed error', async () => {
|
||||
const { client, close } = await connectPair(
|
||||
{ ...FILESYSTEM_STYLE_SCHEMA, $schema: 'http://json-schema.org/draft-04/schema#' },
|
||||
() => ({ content: 'hello' })
|
||||
);
|
||||
|
||||
await expect(client.callTool({ name: 'read_text_file' })).rejects.toThrow(/invalid outputSchema.*unsupported dialect/s);
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('fromJsonSchema registers a draft-07 inputSchema and enforces it server-side', async () => {
|
||||
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' });
|
||||
mcpServer.registerTool(
|
||||
'echo',
|
||||
{
|
||||
inputSchema: fromJsonSchema<{ content: string; encoding?: 'utf8' | 'base64' }>(
|
||||
FILESYSTEM_STYLE_SCHEMA as unknown as JsonSchemaType
|
||||
)
|
||||
},
|
||||
async args => ({ content: [{ type: 'text', text: JSON.stringify(args) }] })
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
||||
|
||||
await expect(client.callTool({ name: 'echo', arguments: { content: 'hi' } })).resolves.toMatchObject({
|
||||
content: [{ type: 'text', text: expect.stringContaining('hi') }]
|
||||
});
|
||||
|
||||
// Violates the draft-07 schema (`content` missing) — the server reports an input validation error.
|
||||
await expect(client.callTool({ name: 'echo', arguments: { encoding: 'utf8' } })).resolves.toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: expect.stringContaining("must have required property 'content'") }]
|
||||
});
|
||||
|
||||
await Promise.all([client.close(), clientTransport.close(), serverTransport.close()]);
|
||||
});
|
||||
});
|
||||
|
|
@ -986,3 +986,32 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
|
|||
).rejects.toThrow(/^Elicitation response content does not match requested schema/);
|
||||
});
|
||||
}
|
||||
|
||||
describe('declared-dialect requestedSchema (default validator)', () => {
|
||||
test('draft-07-stamped requestedSchema validates accepted content with a real engine', async () => {
|
||||
const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const requestedSchema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', minLength: 1 } },
|
||||
required: ['name']
|
||||
} as const;
|
||||
|
||||
let content: Record<string, unknown> = { name: 'John' };
|
||||
client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content }));
|
||||
|
||||
await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
});
|
||||
|
||||
content = { name: '' }; // violates minLength — the draft-07 engine actually runs
|
||||
await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).rejects.toThrow(
|
||||
/does not match requested schema/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue