diff --git a/.changeset/validator-dialect-dispatch.md b/.changeset/validator-dialect-dispatch.md new file mode 100644 index 0000000000..533c2acc9e --- /dev/null +++ b/.changeset/validator-dialect-dispatch.md @@ -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). diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 0b85f2fc87..76ddd5fe8d 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -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,15 @@ 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 @@ -1465,13 +1470,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.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | -| `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.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | +| `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 diff --git a/packages/core-internal/src/validators/ajvProvider.ts b/packages/core-internal/src/validators/ajvProvider.ts index 89768093a1..e33adb741f 100644 --- a/packages/core-internal/src/validators/ajvProvider.ts +++ b/packages/core-internal/src/validators/ajvProvider.ts @@ -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 = 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)); } - getValidator(schema: JsonSchemaType): JsonSchemaValidator { - // 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(schema: JsonSchemaType): JsonSchemaValidator { + const engine = this._engineFor(schema); const ajvValidator = '$id' in schema && typeof schema.$id === 'string' ? (engine.getSchema(schema.$id) ?? engine.compile(schema)) diff --git a/packages/core-internal/src/validators/cfWorkerProvider.ts b/packages/core-internal/src/validators/cfWorkerProvider.ts index 8af5ff6393..faa56b57fd 100644 --- a/packages/core-internal/src/validators/cfWorkerProvider.ts +++ b/packages/core-internal/src/validators/cfWorkerProvider.ts @@ -10,6 +10,7 @@ import { Validator } from '@cfworker/json-schema'; +import { declaredDialect } from './dialects'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types'; /** @@ -17,21 +18,13 @@ 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 = 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) @@ -58,14 +51,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 +78,7 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @returns A validator function that validates input data */ getValidator(schema: JsonSchemaType): JsonSchemaValidator { - // 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[0], draft, this.shortcircuit); diff --git a/packages/core-internal/src/validators/dialects.ts b/packages/core-internal/src/validators/dialects.ts new file mode 100644 index 0000000000..78a2f56db3 --- /dev/null +++ b/packages/core-internal/src/validators/dialects.ts @@ -0,0 +1,52 @@ +/** + * 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 = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'http://json-schema.org/draft/2020-12/schema' +]); +const DRAFT_2019_09_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2019-09/schema', + 'http://json-schema.org/draft/2019-09/schema' +]); +const DRAFT_07_URIS: ReadonlySet = new Set(['https://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema']); +const DRAFT_06_URIS: ReadonlySet = 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'; + +/** + * 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}` + ); +} diff --git a/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts b/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts index 9e80dd3c31..5cd8f5b465 100644 --- a/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts +++ b/packages/core-internal/src/wire/rev2025-11-25/legacyWrap.ts @@ -44,9 +44,67 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet = 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('#')); +} + +/** + * Position-aware scan for a keyword-position `$recursiveAnchor: true` (2019-09). Its presence + * anywhere in the document switches `$recursiveRef` from static (`$ref`-equivalent) to dynamic + * re-resolution, which relocation cannot preserve — see the coverage block below. + */ +function hasRecursiveAnchor(node: unknown, parentIsNameMap: boolean): boolean { + if (Array.isArray(node)) return node.some(item => hasRecursiveAnchor(item, false)); + if (node === null || typeof node !== 'object') return false; + return Object.entries(node).some(([k, v]) => { + if (parentIsNameMap) return hasRecursiveAnchor(v, false); + if (k === '$recursiveAnchor') return v === true; + if (REF_REWRITE_DATA_POSITION_KEYS.has(k)) return false; + return hasRecursiveAnchor(v, REF_REWRITE_NAME_MAP_KEYS.has(k)); + }); +} + +/* + * 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): value is restricted to `#`. Anchor-less documents: converted to + * `$ref: '#/properties/result'` (statically equivalent). Documents with a keyword-position + * `$recursiveAnchor`: left verbatim — KNOWN LIMITATION: relocation cannot preserve dynamic + * re-resolution (a static rewrite would freeze it and the envelope root carries no anchor), + * so anchored recursion still mis-resolves on the 2025 projection. + * - `$recursiveAnchor` (2019-09): boolean, not a location — only consulted by the scan above. + * - `$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:}, required:['result']}`. @@ -60,32 +118,40 @@ const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet = 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 { // 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'] }; } + // Anchor-less documents only: `$recursiveRef: '#'` is statically `$ref: '#'`-equivalent + // and is converted to the rewritten pointer. See the coverage block above. + const convertRecursiveRefs = !hasRecursiveAnchor(natural, false); 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)['$id'] !== undefined) return node; + if (!parentIsNameMap && establishesNewBase((node as Record)['$id'])) return node; const out: Record = {}; for (const [k, v] of Object.entries(node)) { if (parentIsNameMap) { @@ -94,6 +160,8 @@ export function wrapOutputSchemaForLegacy(natural: Readonly { 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(); + }); }); diff --git a/packages/core-internal/test/validators/validators.test.ts b/packages/core-internal/test/validators/validators.test.ts index 3318b94d10..1cfefaad5f 100644 --- a/packages/core-internal/test/validators/validators.test.ts +++ b/packages/core-internal/test/validators/validators.test.ts @@ -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,115 @@ 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: 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. diff --git a/packages/core-internal/test/wire/legacyWrap.test.ts b/packages/core-internal/test/wire/legacyWrap.test.ts index dfca0fa6eb..4978a417ed 100644 --- a/packages/core-internal/test/wire/legacyWrap.test.ts +++ b/packages/core-internal/test/wire/legacyWrap.test.ts @@ -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,161 @@ 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' }); + }); +}); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index c81783d509..76b597c5eb 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -516,7 +516,7 @@ export const REQUIREMENTS: Record = { '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', diff --git a/test/integration/test/client/outputSchemaDialect.test.ts b/test/integration/test/client/outputSchemaDialect.test.ts new file mode 100644 index 0000000000..9d79b29001 --- /dev/null +++ b/test/integration/test/client/outputSchemaDialect.test.ts @@ -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 }> { + 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 + })); + + 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()]); + }); +}); diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 5963229f0a..13fb77e944 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -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 = { 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/ + ); + }); +});