diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md new file mode 100644 index 0000000000..b8ae480eb9 --- /dev/null +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -0,0 +1,34 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or another +unrepresentable type such as `z.bigint()`) in a registered tool's schema no longer throws +during conversion and fails the entire `tools/list` response — dates are advertised as +`{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` actually produces), and +other unrepresentable types degrade to an unconstrained schema. (BigInt values embedded as +defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them — +and so do dynamic catch values, `.catch(ctx => …)`; the `.catch()` degrade covers static +fallback values only.) +Output schemas no longer advertise constraints the server doesn't enforce on the raw +`structuredContent` it ships: fields that may be legitimately absent (`.default()`, +undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — +and `additionalProperties: false` is dropped for plain `z.object()` (kept for +`z.strictObject()`), so validating clients no longer reject legitimate tool results for +these schema shapes. (Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still +advertise the post-transform shape while the server ships the raw pre-transform value — a +pre-existing gap this change does not address. And on zod 4.0–4.2.x, `toJSONSchema` skips +the sanitization hook on a schema reused both bare and via a `.describe()`/`.meta()` clone +in the same conversion; full per-node sanitization requires zod >=4.3.0. And the +`z.date()` advertisement assumes a serializing transport: `InMemoryTransport` passes the +raw `Date` by reference, so a validating client rejects it over that testing transport. +On the input side, a required tool/prompt argument of a type JSON cannot carry makes the +tool listed yet uncallable: `z.date()` is advertised as `string`/`date-time` and other +unrepresentable types (`z.bigint()`, `z.map()`, `z.set()`, `z.symbol()`) as an +unconstrained `{}`, but input validation still runs the raw zod schema, which rejects +every JSON payload — use a JSON-representable type such as `z.iso.date()`/ +`z.iso.datetime()`, `z.number()`, `z.record(...)`, or `z.array(...)`, or make the field +optional.) Elicitation is unaffected: +`inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot +round-trip, including `z.date()`. diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index fc728cdcef..92e61cdbd4 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -28,7 +28,12 @@ function isJsonObject(value: unknown): value is Record { function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Record { try { - return standardSchemaToJsonSchema(schema, 'input'); + // `unrepresentable: 'throw'`: the restricted form grammar must reject shapes it + // cannot round-trip. A `z.date()` rewritten to `string`/`date-time` would pass the + // wire checks, but the accepted response (a JSON string) could never satisfy the + // same `z.date()` schema on handler re-entry — keep the documented loud failure + // (`z.iso.date()`/`z.iso.datetime()` are the supported ways to elicit dates). + return standardSchemaToJsonSchema(schema, 'input', { unrepresentable: 'throw' }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new ProtocolError( diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d904c7f5fa..f74355e7c0 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -169,6 +169,336 @@ let warnedZodFallback = false; /** JSON Schema draft targeted by every conversion; shared so pattern references above stay in lockstep. */ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; +/** + * Zod-specific `toJSONSchema` options, passed as `libraryOptions` on the Standard JSON + * Schema path (scoped to `vendor === 'zod'`) and spread into the zod 4.0–4.1 + * `z.toJSONSchema()` fallback. + * + * The SDK validates payloads with the user's zod schema but ships the tool's *raw* + * object — validation never replaces `structuredContent` — so the advertised schema + * must describe the raw object's serialized wire form (#2464): + * + * - `unrepresentable: 'any'`: a single unrepresentable type (e.g. `z.bigint()`) degrades + * to an unconstrained `{}` instead of throwing and failing the entire `tools/list`. + * (BigInt *values* embedded as defaults or metadata — `.default(0n)`, `.meta({default: 1n})` + * — still throw: zod JSON-round-trips them in its own processors, outside this hook's reach.) + * - `z.date()` is rewritten to `{type: 'string', format: 'date-time'}` — the shape + * `JSON.stringify` actually produces for a `Date` (and what the zod 3 converter emitted). + * - Output objects drop `additionalProperties: false` unless the object is strict: + * zod validation tolerates unknown keys on plain `z.object()`, and the raw payload + * ships them. + * - Output objects and enum-keyed records drop properties that may be legitimately + * absent from the shipped payload (`.default()`, undefined-accepting types) from + * `required`: zod fills defaults during validation, but ships the raw object. + * - Output `.catch()` nodes drop their constraint keywords (`properties`, `required`, + * `additionalProperties`, and any non-object `type`, all unenforced on the raw + * value) but keep an emitted `type: 'object'` (with composition keywords reduced to + * member type skeletons), annotations, and `default`: catch-validation accepts any + * raw value — the fallback replaces it only in the parsed result, which the server + * never ships. The object-type signal is kept at every position (zod deduplicates + * reused instances, so the verdict must be position-independent) and is all the + * 2025-era legacy-wrap object proof consumes at the root and in root-level + * compositions. + * + * Known residual gaps: + * - A REQUIRED input field (tool `inputSchema`, prompt `argsSchema`) of a type JSON + * cannot carry makes the tool listed yet uncallable: `z.date()` advertises + * `string`/`date-time` and other unrepresentable types (`z.bigint()`, `z.map()`, + * `z.set()`, `z.symbol()`) an unconstrained `{}`, but input validation still runs + * the raw zod schema, which rejects every JSON payload. Use a JSON-representable + * type (`z.iso.date()`/`z.iso.datetime()`, `z.number()`, `z.record(...)`, + * `z.array(...)`) or make the field optional. + * - Dynamic catch values (`.catch(ctx => …)`) still throw inside zod's own + * catchProcessor before this hook runs ("Dynamic catch values are not supported + * in JSON Schema"), so one such tool still fails the entire `tools/list` — the + * degrade below covers static `.catch(value)` only. + * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the + * post-transform shape (`io: 'output'`) even though the server validates and ships + * the raw pre-transform value — rewriting pipe nodes to their input side per-node + * would break `$ref`s to registered schemas, and converting output advertisements + * with input semantics wholesale is a design decision that interacts with SEP-2106 + * non-object output roots (see #2464 discussion). + * - On zod 4.0–4.2.x, `toJSONSchema` skips the `override` hook on any node whose + * clone (`.describe()`/`.meta()`) appears in the same conversion (the + * `if (!seen.isParent)` guard in `v4/core/to-json-schema.js`, removed in zod + * 4.3.0), so a schema reused both bare and via a clone leaves the bare node + * unsanitized. Full per-node sanitization requires zod >=4.3.0. + * - The `z.date()` → `string`/`date-time` advertisement assumes a serializing + * transport. `InMemoryTransport` passes messages by reference with no JSON + * round-trip, so the raw `Date` the server must ship reaches a validating client + * as a `Date` instance and fails the advertised schema there. + */ +function zodConversionOptions( + io: 'input' | 'output', + loosened: { value: boolean } +): Pick { + return { + unrepresentable: 'any', + override: ctx => { + const def = ctx.zodSchema._zod.def; + if (def.type === 'date') { + // Under `unrepresentable: 'any'` the node carries only user annotations + // (`.describe()` / `.meta()`) — keep them and stamp the wire shape beside them. + ctx.jsonSchema.type = 'string'; + ctx.jsonSchema.format = 'date-time'; + return; + } + if (io !== 'output') return; + if (def.type === 'catch') { + // `.catch()` accepts any raw value — invalid input is replaced by the + // fallback only in the parsed result, which the server never ships — so + // no inner constraint is enforced on the wire: drop the constraint + // keywords — including a non-object `type`, which would reject the + // wrong-typed raw values `.catch()` exists to tolerate — but KEEP an + // emitted `type: 'object'` and reduce composition keywords + // (`anyOf`/`oneOf`/`allOf`, emitted when the catch wraps a union or + // intersection) to member type skeletons. `type: 'object'` is all the + // 2025-era legacy-wrap object proof consumes, and the verdict must be + // position-independent — zod deduplicates reused instances and runs + // this hook once per instance, so one shared node can sit at both a + // nested position and a root(-composition) position — losing the + // object-type signal at the latter would flip the legacy-wrap + // predicate (`isNonObjectJsonSchemaRoot`) and silently change the + // wire shape. + loosened.value = true; + for (const key of Object.keys(ctx.jsonSchema)) { + // `x-*` vendor extensions are annotation-only (same convention as the + // elicitation walker) and carry no validation constraint. + if (ANNOTATION_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-')) continue; + if (key === 'type' && ctx.jsonSchema.type === 'object') continue; + if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf') && Array.isArray(ctx.jsonSchema[key])) { + const skeletons = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + // `oneOf` means EXACTLY one: with member constraints stripped, the + // skeletons are indistinguishable and every payload would match all + // of them — advertise the honest loosening `anyOf` instead + // (wrap-neutral: `isProvablyObjectShapedRoot` treats the + // composition keywords identically). + if (key === 'oneOf') delete ctx.jsonSchema[key]; + ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; + continue; + } + delete ctx.jsonSchema[key]; + } + return; + } + if (def.type === 'record') { + // Enum-keyed records emit a `required` list too. Every key shares the one + // value schema, so tolerance for missing keys is all-or-nothing. + if (Array.isArray(ctx.jsonSchema.required) && fieldAcceptsMissingKey(def.valueType)) { + loosened.value = true; + delete ctx.jsonSchema.required; + } + return; + } + if (def.type !== 'object') return; + const isStrict = def.catchall?._zod.def.type === 'never'; + if (!isStrict && ctx.jsonSchema.additionalProperties === false) { + delete ctx.jsonSchema.additionalProperties; + } + const required = ctx.jsonSchema.required; + if (Array.isArray(required)) { + // Keyed on the zod shape, not the emitted JSON: a registered `.default()` + // hides its `default` keyword behind a `$ref`, and undefined-accepting + // fields (`z.any()`, `z.unknown()`, …) never emit one yet may be dropped + // from the wire payload by JSON.stringify. + const filtered = required.filter(name => !fieldAcceptsMissingKey(def.shape[name])); + if (filtered.length !== required.length) { + loosened.value = true; + if (filtered.length === 0) delete ctx.jsonSchema.required; + else ctx.jsonSchema.required = filtered; + } + } + } + }; +} + +/** + * Recursively moves every `oneOf` in an emitted (already-loosened) JSON Schema tree + * to `anyOf`. Used when a conversion's catch degrade or required-filter fired: the + * loosened members of an untouched parent `oneOf` (e.g. a discriminated union whose + * catch-wrapped discriminators were degraded) may have become mutually satisfiable, + * inverting exactly-one semantics into reject-everything. + */ +function rewriteOneOfToAnyOf(node: unknown, seen: Set = new Set()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) rewriteOneOfToAnyOf(item, seen); + return; + } + const record = node as Record; + if (Array.isArray(record.oneOf) && record.anyOf === undefined) { + record.anyOf = record.oneOf; + delete record.oneOf; + } + for (const value of Object.values(record)) rewriteOneOfToAnyOf(value, seen); +} + +/** + * Reduces a degraded node's composition member to its type skeleton — `type` plus + * recursively-skeletonized nested compositions, nothing else — so the output + * epilogue's `isProvablyObjectShapedRoot` proof survives the `.catch()` degrade + * without advertising any unenforced member constraint. + */ +function compositionTypeSkeleton(node: unknown): Record { + if (typeof node !== 'object' || node === null) return {}; + const source = node as Record; + const skeleton: Record = {}; + // Only `type: 'object'` feeds the proof; any other type is an unenforced constraint. + if (source.type === 'object') skeleton.type = 'object'; + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(source[key])) { + // Skeletonized `oneOf` members are indistinguishable, so exactly-one + // semantics would reject every payload — emit `anyOf` instead. + skeleton[key === 'oneOf' ? 'anyOf' : key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + } + } + return skeleton; +} + +/** + * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) + * preserved when a node's constraints are degraded because validation does not enforce + * them on the raw value (`.catch()` nodes). + */ +const ANNOTATION_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + '$comment', + 'default', + 'deprecated', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly' +]); + +/** + * Whether a raw payload that omits this field still passes validation (zod treats a + * missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`, + * `z.unknown()`, `z.undefined()`, and unions with them), so the wire schema must not + * advertise the field as `required`. A probe that throws, rejects, or goes async (a + * `.transform()` choking on `undefined` does all three depending on the zod version) + * cannot demonstrate tolerance, so such fields conservatively stay required. + */ +function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { + if (field === undefined) return false; + // Defaulted fields accept a missing key by construction (zod fills the default + // before any refinement or transform runs) — decide structurally, since an async + // stage (`.refine(async ...)`, `.transform(async ...)`) would push the probe + // below to a Promise and wrongly keep the field required. The walk also covers + // static `.catch()`, undefined-accepting leaves (`z.any()`/`z.unknown()`), + // Symbol-/function-typed fields (JSON.stringify drops such keys entirely), union + // members, and defaults hidden inside a pipe (`.default(7).transform(...)`, + // `z.preprocess(fn, z.number().default(7))`). + if (hasStructuralMissingKeyTolerance(field)) return true; + try { + const result = field['~standard'].validate(undefined); + if (result instanceof Promise) { + // Never leave a floating rejection: an unhandled one crashes the process. + result.catch(() => {}); + return false; + } + return result.issues === undefined; + } catch { + return false; + } +} + +/** + * Whether the field's def chain carries a node that makes a missing key tolerable by + * construction — `default`/`prefault` (the default fills), a static `catch` (any + * input, including `undefined`, is replaced by the fallback), `optional`, an + * undefined-accepting leaf (`z.any()`/`z.unknown()`/`z.undefined()`/`z.void()`, or a + * literal whose values include `undefined`), or a Symbol-/function-typed leaf + * (JSON.stringify drops such keys from the payload entirely) — unwinding pipe sides, + * lazies, transparent wrappers, union members (ANY tolerant member suffices: zod + * tries members and the tolerant one succeeds), and intersections with EVERY-side + * semantics (`undefined` must parse through both sides). Deciding structurally matters + * because an async stage (`.refine(async ...)`, `.transform(async ...)`) pushes the + * validate-probe to a Promise. All checks err loosen-only: a false positive merely + * drops a field from the advertised `required`, which can never make a validating + * client reject a shipped payload. `ancestors` tracks the current traversal path so + * recursive lazies stay bounded. + */ +function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet = new Set()): boolean { + if (typeof field !== 'object' || field === null || ancestors.has(field)) return false; + const def = ( + field as { + _zod?: { + def?: { + type?: string; + innerType?: unknown; + getter?: unknown; + in?: unknown; + out?: unknown; + options?: unknown; + left?: unknown; + right?: unknown; + values?: unknown; + }; + }; + } + )._zod?.def; + if (def === undefined || typeof def.type !== 'string') return false; + // `catch` and `optional` must be recognized BEFORE the wrapper unwind below + // would step past the very node granting tolerance (bare `.optional()` fields + // are already excluded from `required` by zod's emitter, but one inside a pipe + // — `z.string().optional().transform(async ...)` — is not). + if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch' || def.type === 'optional') return true; + if (def.type === 'any' || def.type === 'unknown' || def.type === 'undefined' || def.type === 'void') return true; + if (def.type === 'symbol' || def.type === 'function') return true; + if (def.type === 'literal' && Array.isArray(def.values) && def.values.includes(undefined)) return true; + const path = new Set(ancestors); + path.add(field); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return hasStructuralMissingKeyTolerance((def.getter as () => unknown)(), path); + } catch { + return false; + } + } + if (def.type === 'pipe' && def.in !== undefined) { + if (hasStructuralMissingKeyTolerance(def.in, path)) return true; + // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at + // `def.in` and the tolerant node (e.g. a default) at `def.out`. + const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; + if (inDef?.type === 'transform' && def.out !== undefined) { + return hasStructuralMissingKeyTolerance(def.out, path); + } + return false; + } + if (def.type === 'union' && Array.isArray(def.options)) { + return def.options.some(option => hasStructuralMissingKeyTolerance(option, path)); + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + // EVERY-side semantics: `undefined` must parse through BOTH sides (each + // filling its default) for zod to merge the results. + return hasStructuralMissingKeyTolerance(def.left, path) && hasStructuralMissingKeyTolerance(def.right, path); + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return hasStructuralMissingKeyTolerance(def.innerType, path); + } + return false; +} + +/** Options for {@linkcode standardSchemaToJsonSchema}. */ +export interface StandardSchemaToJsonSchemaOptions { + /** + * How types JSON Schema cannot represent (`z.date()`, `z.bigint()`, …) are handled + * for zod schemas: + * + * - `'wire'` (default) — degrade gracefully: `z.date()` becomes + * `{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` puts on the + * wire for a `Date`) and other unrepresentable types become an unconstrained + * schema, so one field cannot fail an entire `tools/list` response (#2464). + * - `'throw'` — surface zod's conversion error. The elicitation path uses this: its + * restricted form grammar must reject shapes it cannot round-trip, and a silently + * rewritten `string`/`date-time` request would elicit a string that the original + * `z.date()` schema can never re-validate on handler re-entry. + */ + unrepresentable?: 'wire' | 'throw'; +} + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * @@ -180,11 +510,21 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * `io: 'output'` a non-object root is returned as-is; the `"object"` default is * applied only when the root is provably object-shaped. */ -export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { +export function standardSchemaToJsonSchema( + schema: StandardJSONSchemaV1, + io: 'input' | 'output' = 'input', + options?: StandardSchemaToJsonSchemaOptions +): Record { const std = schema['~standard']; + const loosened = { value: false }; + const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io, loosened); let result: Record; if (std.jsonSchema) { - result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET }); + result = std.jsonSchema[io]({ + target: JSON_SCHEMA_CONVERSION_TARGET, + // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. + libraryOptions: std.vendor === 'zod' ? zodOptions : undefined + }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. @@ -203,13 +543,27 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' ); } - result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) as Record; + result = z.toJSONSchema(schema as unknown as z.ZodType, { + target: JSON_SCHEMA_CONVERSION_TARGET, + io, + ...zodOptions + }) as Record; } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } + if (io === 'output' && loosened.value) { + // Exactly-one semantics cannot survive member loosening: once the catch + // degrade or the required-filter fired anywhere in this conversion, `oneOf` + // members (zod's discriminated-union emission) may have become mutually + // satisfiable — e.g. catch-wrapped discriminators — and Ajv would reject + // every payload with "must match exactly one schema in oneOf". Rewrite to + // the honest `anyOf` (loosen-only and wrap-neutral: + // `isProvablyObjectShapedRoot` treats the composition keywords identically). + rewriteOneOfToAnyOf(result); + } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or // not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is @@ -231,9 +585,177 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in `Wrap your schema in z.object({...}) or equivalent.` ); } + // `unrepresentable: 'any'` erases the explicit non-object `type` the guard above keys + // on (e.g. a bare `z.bigint()` root emits `{}`); recover the signal from the zod def + // so misregistered roots keep failing loudly instead of being advertised as + // permanently-uncallable `{type: 'object'}` tools. + if (result.type === undefined) { + const verdict = nonObjectTypelessRootVerdict(schema); + // Quiet verdicts (compositions of only non-finite number literals) listed + // silently pre-#2464 and keep the unconditional stamp below. + if (verdict !== undefined && verdict.loud) { + throw new Error( + `MCP tool and prompt schemas must describe objects (got a non-object ${verdict.type} schema). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } + } return { type: 'object', ...result }; } +/** + * The verdict for a zod root that emits a typeless node yet provably cannot describe + * an object, or `undefined` when the root may. Unwraps transparent wrappers, lazies, + * and pipe input sides, then recurses into compositions: a union is non-object when + * EVERY member is, an intersection when ANY side is (the value must satisfy both). A + * member counts as non-object ONLY when it unwinds to a genuinely unrepresentable + * type, or to a literal carrying unrepresentable values — representable members + * (including single-type literals, zod's idiomatic enum spelling, and representable + * non-object types like `z.string()`) keep the composition accepted, exactly as such + * roots converted before #2464. + * + * The verdict carries loudness for the loudness-parity contract: `loud` shapes made + * pre-#2464 conversion throw (bigint/symbol/date/…, literals with undefined/bigint/ + * symbol values) and must keep throwing; `quiet` shapes (non-finite number literals, + * which zod silently emits as `{type: 'number', const: null}`) listed silently + * pre-#2464 and must keep listing — the guard throws only for a loud-containing + * verdict. `ancestors` tracks only the current traversal path, so a shared instance + * gets a real verdict at every occurrence while recursive lazies stay bounded. + */ +function nonObjectTypelessRootVerdict( + schema: unknown, + ancestors: ReadonlySet = new Set() +): { type: string; loud: boolean } | undefined { + if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; + const def = ( + schema as { + _zod?: { + def?: { + type?: string; + innerType?: unknown; + getter?: unknown; + in?: unknown; + out?: unknown; + options?: unknown; + left?: unknown; + right?: unknown; + values?: unknown; + }; + }; + } + )._zod?.def; + if (def === undefined || typeof def.type !== 'string') return undefined; + const path = new Set(ancestors); + path.add(schema); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), path); + } catch { + return undefined; + } + } + if (def.type === 'pipe' && def.in !== undefined) { + const inVerdict = nonObjectTypelessRootVerdict(def.in, path); + if (inVerdict !== undefined) return inVerdict; + // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at + // `def.in` (verdict undefined by design) and the real schema at `def.out`. + const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; + if (inDef?.type === 'transform' && def.out !== undefined) { + return nonObjectTypelessRootVerdict(def.out, path); + } + return undefined; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return nonObjectTypelessRootVerdict(def.innerType, path); + } + if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { + const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, path)); + if (verdicts.includes(undefined)) return undefined; + return { type: 'union', loud: verdicts.some(verdict => verdict?.loud === true) }; + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + const left = nonObjectTypelessRootVerdict(def.left, path); + const right = nonObjectTypelessRootVerdict(def.right, path); + if (left === undefined && right === undefined) return undefined; + return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; + } + if (def.type === 'literal') { + const loudness = nonObjectLiteralLoudness(def.values); + return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' }; + } + return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; +} + +/** + * The non-object loudness of a literal's values, or `undefined` when the literal may + * be JSON-satisfiable. `'loud'`: a value zod's own converter threw on pre-#2464 + * (`undefined`, bigint, symbol) — such roots must keep failing loudly. `'quiet'`: + * only non-finite numbers (`Infinity`/`-Infinity`/`NaN`), which zod silently emits + * as `{type: 'number', const: null}` — non-object, but such roots listed silently + * pre-#2464 and must keep listing. Any representable value (string, finite number, + * boolean, null) makes the literal satisfiable via JSON: `undefined` + * (`z.union([z.literal('admin'), z.literal('member')])`, zod's idiomatic enum + * spelling, and mixed-type lists like `z.literal(['a', 1])` all keep converting + * exactly as they did pre-#2464). + */ +function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined { + if (!Array.isArray(values) || values.length === 0) return 'loud'; + let representable = false; + let nonFinite = false; + for (const value of values) { + if (value === null) { + representable = true; + continue; + } + const valueType = typeof value; + if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { + return 'loud'; // undefined / bigint / symbol values threw pre-#2464 + } + if (valueType === 'number' && !Number.isFinite(value as number)) { + nonFinite = true; + } else { + representable = true; + } + } + if (representable) return undefined; + return nonFinite ? 'quiet' : undefined; +} + +/** + * Zod def types that are genuinely unrepresentable in JSON Schema and whose values can + * never be a JSON object — the input-root guard must keep rejecting roots and + * compositions built from them. Most degrade to a typeless `{}` under + * `unrepresentable: 'any'`; `date` is instead rewritten to `string`/`date-time` (so a + * bare date ROOT throws via the explicit-type guard), but a `Date` value can never be + * a JSON object either, so date composition MEMBERS classify as non-object here. + * `custom` and bare `transform` are deliberately excluded (they can legitimately + * accept objects), and typeless literals are classified separately by value in + * {@linkcode nonObjectLiteralLoudness}. + */ +const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ + 'bigint', + 'symbol', + 'map', + 'set', + 'void', + 'undefined', + 'nan', + 'function', + 'date' +]); + +/** Transparent wrapper def types whose `innerType` carries the real root semantics. */ +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set([ + 'optional', + 'nonoptional', + 'nullable', + 'readonly', + 'default', + 'prefault', + 'catch', + 'promise' +]); + /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index bdc8187ac8..6f1ef3e04d 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -160,6 +160,18 @@ describe('inputRequired() builder', () => { requestedSchema: z.object({ role: z.union([z.literal('admin'), z.literal('member')]) }) }) ).toThrow(TypeError); + + // z.date() must keep failing loudly even though the tools-path conversion rewrites + // it to string/date-time (#2464): the accepted response (a JSON string) could never + // satisfy z.date() on handler re-entry, so acceptedContent() would silently return + // undefined. z.iso.date()/z.iso.datetime() are the supported ways to elicit dates. + const rejectDate = () => + inputRequired.elicit({ + message: 'When?', + requestedSchema: z.object({ when: z.date() }) + }); + expect(rejectDate).toThrow(TypeError); + expect(rejectDate).toThrow(/Date cannot be represented/); }); test.each([ diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 8856592ff0..9a3a699f0c 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1,6 +1,8 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; +import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { @@ -40,3 +42,518 @@ describe('standardSchemaToJsonSchema', () => { expect(result.type).toBe('object'); }); }); + +describe('zod conversion options (#2464)', () => { + test('z.date() converts to string/date-time instead of throwing (input)', () => { + const result = standardSchemaToJsonSchema(z.object({ when: z.date() }), 'input'); + + expect((result.properties as Record).when).toEqual({ type: 'string', format: 'date-time' }); + }); + + test('z.date() converts to string/date-time instead of throwing (output)', () => { + const result = standardSchemaToJsonSchema(z.object({ when: z.date() }), 'output'); + + expect((result.properties as Record).when).toEqual({ type: 'string', format: 'date-time' }); + }); + + test('other unrepresentable types degrade to an unconstrained schema instead of throwing', () => { + const result = standardSchemaToJsonSchema(z.object({ big: z.bigint() }), 'input'); + + expect((result.properties as Record).big).toEqual({}); + }); + + test('z.date() keeps user annotations alongside the rewritten wire shape', () => { + const result = standardSchemaToJsonSchema(z.object({ when: z.date().describe('event timestamp') }), 'input'); + + expect((result.properties as Record).when).toEqual({ + type: 'string', + format: 'date-time', + description: 'event timestamp' + }); + }); + + test("unrepresentable: 'throw' restores zod's conversion error (elicitation contract)", () => { + expect(() => standardSchemaToJsonSchema(z.object({ when: z.date() }), 'input', { unrepresentable: 'throw' })).toThrow( + /Date cannot be represented/ + ); + }); + + test('defaulted fields are not advertised as required in output schemas', () => { + const schema = z.object({ counted: z.number().default(0), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The server ships the tool's raw structuredContent without filling defaults, + // so a payload omitting `counted` must satisfy the advertised schema. + expect(result.required).toEqual(['name']); + }); + + test('a registered .default() (emitted as $ref) is still dropped from output required', () => { + const schema = z.object({ counted: z.number().default(0).meta({ id: 'StandardSchemaTestCounted' }), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The `default` keyword hides inside $defs behind a bare $ref; the filter must + // key on the zod shape, not the emitted JSON. + expect((result.properties as Record>).counted?.$ref).toBeDefined(); + expect(result.required).toEqual(['name']); + }); + + test('a required field annotated with .meta({default}) stays required in output schemas', () => { + const schema = z.object({ label: z.string().meta({ default: 'n/a' }), other: z.number() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The annotation carries a `default` keyword, but validation still requires the + // field, so every shipped payload carries it. + expect(result.required).toEqual(['label', 'other']); + }); + + test('undefined-accepting fields are not advertised as required in output schemas', () => { + const schema = z.object({ a: z.any(), u: z.unknown(), v: z.undefined(), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A raw payload with an undefined-valued key passes validation, and + // JSON.stringify drops the key from the wire entirely. + expect(result.required).toEqual(['name']); + }); + + test('enum-keyed records with a defaulted value drop the emitted required list (output)', () => { + const schema = z.object({ tallies: z.record(z.enum(['likes', 'shares']), z.number().default(0)), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // zod fills record defaults during validation, so `{}` is a legitimate raw value + // for the record node — but the record field itself stays required on the parent. + const tallies = (result.properties as Record>).tallies!; + expect(tallies.required).toBeUndefined(); + expect(result.required).toEqual(['tallies', 'name']); + }); + + test('enum-keyed records with a strict value keep the emitted required list (output)', () => { + const schema = z.record(z.enum(['likes', 'shares']), z.number()); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Validation rejects a missing key here, so the required list is truthful. + expect(result.required).toEqual(['likes', 'shares']); + }); + + test('a defaulted field with an async stage is still dropped from output required', () => { + const schema = z.object({ + d: z + .number() + .default(0) + .refine(async () => true), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The async refine pushes the validate(undefined) probe to a Promise, but a + // defaulted field accepts a missing key by construction — decided structurally. + expect(result.required).toEqual(['name']); + }); + + test('a defaulted field piped through a transform is still dropped from output required', () => { + const schema = z.object({ + asyncPiped: z + .number() + .default(7) + .transform(async v => v), + syncPiped: z + .number() + .default(7) + .transform(v => v), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `.default(7).transform(...)` is outer-'pipe' with the ZodDefault at def.in; + // the structural check must unwind the pipe's input side, since the async + // stage pushes the validate(undefined) probe to a Promise. + expect(result.required).toEqual(['name']); + }); + + test('.catch() output nodes degrade to an unconstrained schema (annotations kept)', () => { + const schema = z.object({ + inner: z.object({ n: z.string() }).catch({ n: 'd' }).describe('lenient'), + scalar: z.number().catch(0), + annotated: z.number().catch(0).meta({ 'x-ui': 1, title: 't' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Catch-validation accepts any raw value (the fallback replaces it only in the + // parsed result, which never ships), so no inner constraint may be advertised — + // including a non-object `type`, which would reject the wrong-typed raw values + // catch exists to tolerate. Only `type: 'object'` is kept: the verdict must be + // position-independent (zod deduplicates reused instances), and it is all the + // 2025-era legacy-wrap object proof consumes. + const properties = result.properties as Record>; + expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' }, type: 'object' }); + expect(properties.scalar).toEqual({ default: 0 }); + // `x-*` vendor extensions are annotation-only and must survive the degrade. + expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1 }); + // A raw payload omitting the catch fields also validates, so they are not required. + expect(result.required).toEqual(['name']); + }); + + test('a .catch() instance reused at nested and root-proof positions is safe in both orderings', () => { + // zod deduplicates reused instances and runs the override once per instance, + // so the degrade verdict is shared by every occurrence — it must not depend + // on which position is seen first. + for (const makeUnion of [ + (c: z.ZodType) => z.union([z.object({ x: c }), c]), // nested seen first + (c: z.ZodType) => z.union([c, z.object({ x: c })]) // root member seen first + ]) { + const shared = z.object({ q: z.string() }).catch({ q: 'd' }); + const result = standardSchemaToJsonSchema(makeUnion(shared), 'output'); + + // The root object proof must hold (no 2025-era legacy-wrap flip) ... + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + const members = result.anyOf as Array>; + const nested = members.find(m => m.properties !== undefined)!; + const rootMember = members.find(m => m.properties === undefined)!; + // ... and no occurrence may advertise the unenforced inner constraints. + expect((nested.properties as Record>).x).toEqual({ default: { q: 'd' }, type: 'object' }); + expect(rootMember).toEqual({ default: { q: 'd' }, type: 'object' }); + } + }); + + test('a root-position .catch() output schema keeps its emitted object root', () => { + const result = standardSchemaToJsonSchema(z.object({ n: z.string() }).catch({ n: 'd' }), 'output'); + + // Degrading the ROOT would delete `type: 'object'` and flip the 2025-era + // codec's legacy-wrap predicate — a silent wire change for such tools. + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + }); + + test('a .catch() member of a root union keeps its emitted shape (root still proves object)', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.string() }).catch({ b: 'd' })]); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Degrading the member would break `isProvablyObjectShapedRoot`'s every-member + // proof, leave the root typeless, and flip the 2025-era legacy wrap — a silent + // wire change for a previously-working registration. + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + expect((result.anyOf as Array>)[1]?.type).toBe('object'); + }); + + test('unrepresentable non-object roots still throw on the input path', () => { + // `unrepresentable: 'any'` degrades these roots to a typeless {}, which must not + // be stamped `type: 'object'` — the tool would be advertised but never callable. + expect(() => standardSchemaToJsonSchema(z.bigint(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.map(z.string(), z.number()), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.set(z.string()), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.symbol(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.void(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.undefined(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.nan(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.function(), 'input')).toThrow(/must describe objects/); + // Wrappers and lazies must not hide a non-object root from the guard. + expect(() => standardSchemaToJsonSchema(z.bigint().optional(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.bigint().nullable(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.bigint().readonly(), 'input')).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema( + z.lazy(() => z.bigint()), + 'input' + ) + ).toThrow(/must describe objects/); + // Literals with genuinely unrepresentable values are not objects. + expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); + // Mixed-but-REPRESENTABLE literals emit a valid {enum: [...]}: they listed + // silently pre-#2464 (an uncallable phantom, but harmless to other tools) + // and must keep doing so — throwing here would fail the whole tools/list. + expect(standardSchemaToJsonSchema(z.literal(['a', 1]), 'input').type).toBe('object'); + // Pipes unwrap via their INPUT side, and promises via their inner type. + expect(() => + standardSchemaToJsonSchema( + z.bigint().transform(x => Number(x)), + 'input' + ) + ).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.bigint().pipe(z.transform((x: bigint) => Number(x))), 'input')).toThrow( + /must describe objects/ + ); + expect(() => standardSchemaToJsonSchema(z.promise(z.bigint()), 'input')).toThrow(/must describe objects/); + // `.nonoptional()` is a transparent wrapper like its siblings. + expect(() => standardSchemaToJsonSchema(z.bigint().nonoptional(), 'input')).toThrow(/must describe objects/); + // Compositions: a union is non-object when EVERY member is, an intersection + // when ANY side is. + expect(() => standardSchemaToJsonSchema(z.union([z.bigint(), z.symbol()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.intersection(z.bigint(), z.bigint()), 'input')).toThrow(/must describe objects/); + // Wrapped OBJECT roots stay accepted. + expect(standardSchemaToJsonSchema(z.object({ a: z.string() }).optional(), 'input').type).toBe('object'); + expect( + standardSchemaToJsonSchema( + z.object({ a: z.string() }).transform(o => o), + 'input' + ).type + ).toBe('object'); + // A union with one representable object member stays accepted. + expect(standardSchemaToJsonSchema(z.union([z.bigint(), z.object({ a: z.string() })]), 'input').type).toBe('object'); + }); + + test('shared-instance union members each get a real verdict', () => { + // The cycle guard tracks only the current traversal path, so a schema constant + // reused across union arms must not short-circuit the second member's verdict. + const shared = z.bigint(); + expect(() => standardSchemaToJsonSchema(z.union([shared, shared]), 'input')).toThrow(/must describe objects/); + }); + + test('representable literal unions keep listing (idiomatic zod enum spelling)', () => { + // Regression: these converted and listed pre-#2464 — classifying every literal + // member as non-object made one such registration fail the ENTIRE tools/list. + expect(standardSchemaToJsonSchema(z.union([z.literal('admin'), z.literal('member')]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.literal('a')), 'input').type).toBe('object'); + // Representable non-object members also keep the composition accepted, exactly + // as such roots converted pre-#2464. + expect(standardSchemaToJsonSchema(z.union([z.string(), z.number()]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.literal('a'), z.null()]), 'input').type).toBe('object'); + // Literals with unrepresentable values still reject inside compositions. + expect(() => standardSchemaToJsonSchema(z.union([z.literal(undefined), z.bigint()]), 'input')).toThrow(/must describe objects/); + }); + + test('date members and non-finite literals classify as non-object inside compositions', () => { + // A Date value can never be a JSON object; at member position the rewritten + // string/date-time node nests inside anyOf and evades the explicit-type guard. + expect(() => standardSchemaToJsonSchema(z.union([z.date(), z.date()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.date(), z.bigint()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.date()), 'input')).toThrow( + /must describe objects/ + ); + // Infinity/-Infinity/NaN literals are typeof 'number' but cannot ride JSON. + expect(() => standardSchemaToJsonSchema(z.union([z.literal(Infinity), z.bigint()]), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.literal(Number.NaN), z.bigint()]), 'input')).toThrow(/must describe objects/); + // A representable member keeps the composition accepted (every-member rule). + expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); + }); + + test('preprocess-wrapped unrepresentable roots throw (transform at def.in, schema at def.out)', () => { + // z.preprocess builds the opposite pipe; the guard must look past the + // transform at def.in to the real schema at def.out. + expect(() => + standardSchemaToJsonSchema( + z.preprocess(v => v, z.bigint()), + 'input' + ) + ).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.preprocess(v => v, z.bigint()), z.symbol()]), 'input')).toThrow( + /must describe objects/ + ); + // Object-rooted preprocess stays accepted; date still throws via the + // explicit-type guard after the rewrite. + expect( + standardSchemaToJsonSchema( + z.preprocess(v => v, z.object({ a: z.string() })), + 'input' + ).type + ).toBe('object'); + expect(() => + standardSchemaToJsonSchema( + z.preprocess(v => v, z.date()), + 'input' + ) + ).toThrow(/must describe objects/); + }); + + test('compositions built solely of non-finite literals keep listing (quiet verdicts)', () => { + // These converted silently pre-#2464 (zod emits {type: 'number', const: null} + // without throwing) — throwing here would fail the whole tools/list. The + // guard throws only when the composition also carries a LOUD member (one + // that made pre-#2464 conversion throw, e.g. a bigint). + expect(standardSchemaToJsonSchema(z.union([z.literal(Infinity), z.literal(-Infinity)]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.literal(Infinity), z.literal(Number.NaN)]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.literal(Infinity)), 'input').type).toBe('object'); + }); + + test('symbol- and function-valued output fields are not advertised as required', () => { + const schema = z.object({ s: z.symbol(), f: z.function(), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // JSON.stringify drops Symbol- and function-valued keys from the payload, so + // no serialized result can ever carry them. + expect(result.required).toEqual(['name']); + }); + + test('a .catch() wrapping a union keeps a composition type skeleton (root still proves object)', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]).catch({ a: 'd' }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The catch node emits {anyOf, default} with no `type`; deleting `anyOf` + // would leave the root typeless and flip the 2025-era legacy wrap. + expect(result.anyOf).toEqual([{ type: 'object' }, { type: 'object' }]); + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + }); + + test('a .catch() wrapping a discriminated union skeletonizes oneOf as anyOf', () => { + const du = z + .discriminatedUnion('t', [z.object({ t: z.literal('a'), x: z.string() }), z.object({ t: z.literal('b'), y: z.string() })]) + .catch({ t: 'a', x: 'd' }); + + // With member constraints stripped, `oneOf` skeletons are indistinguishable — + // every payload would match BOTH members and Ajv rejects "must match exactly + // one schema in oneOf". The honest loosening is `anyOf` (wrap-neutral). + const root = standardSchemaToJsonSchema(du, 'output'); + expect(root.oneOf).toBeUndefined(); + expect(root.anyOf).toEqual([{ type: 'object' }, { type: 'object' }]); + expect(root.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(root)).toBe(false); + const rootValidate = new AjvJsonSchemaValidator().getValidator(root); + expect(rootValidate({ t: 'a', x: 'hello' }).valid).toBe(true); + + const nested = standardSchemaToJsonSchema(z.object({ res: du, name: z.string() }), 'output'); + const res = (nested.properties as Record>).res!; + expect(res.oneOf).toBeUndefined(); + expect(res.anyOf).toEqual([{ type: 'object' }, { type: 'object' }]); + const nestedValidate = new AjvJsonSchemaValidator().getValidator(nested); + expect(nestedValidate({ res: { t: 'a', x: 'hello' }, name: 'n' }).valid).toBe(true); + }); + + test('a discriminated union with catch-wrapped discriminators loosens its oneOf to anyOf', () => { + // The catch degrade strips each discriminator's type/const and the + // required-filter drops 't', so the members become mutually satisfiable — + // the untouched parent's `oneOf` would then reject EVERY payload ("must + // match exactly one schema in oneOf"). + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]); + + const root = standardSchemaToJsonSchema(du, 'output'); + expect(root.oneOf).toBeUndefined(); + expect(Array.isArray(root.anyOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(root)({ t: 'a', x: 'v' }).valid).toBe(true); + + const nested = standardSchemaToJsonSchema(z.object({ res: du, name: z.string() }), 'output'); + const res = (nested.properties as Record>).res!; + expect(res.oneOf).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(nested)({ res: { t: 'a', x: 'v' }, name: 'n' }).valid).toBe(true); + }); + + test('a discriminated union without degraded nodes keeps its oneOf', () => { + // Untouched schemas must not be loosened: exactly-one semantics stay + // truthful while the members keep their discriminating constraints. + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a'), x: z.string() }), + z.object({ t: z.literal('b'), y: z.string() }) + ]); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(Array.isArray(result.oneOf)).toBe(true); + expect(result.anyOf).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + + test('.catch() and union-nested defaults with async stages are dropped from output required', () => { + const schema = z.object({ + c: z + .number() + .catch(0) + .refine(async () => true), + u: z.union([ + z + .number() + .default(7) + .transform(async v => v), + z.string() + ]), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A static .catch() tolerates a missing key by construction (like a default), + // and a union accepts one whenever ANY member does — both must be recognized + // structurally, since the async stages push the validate probe to a Promise. + expect(result.required).toEqual(['name']); + }); + + test('union-wrapped symbols, async any/unknown, and preprocess defaults are dropped from output required', () => { + const schema = z.object({ + s: z.union([z.symbol(), z.string()]), + a: z.any().refine(async () => true), + u: z.unknown().transform(async v => v), + p: z.preprocess(v => v, z.number().default(7)).refine(async () => true), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // JSON.stringify drops Symbol-valued keys whichever union member matched; + // any/unknown accept undefined outright; and z.preprocess builds the + // opposite pipe (transform at def.in, the default at def.out). + expect(result.required).toEqual(['name']); + + // Same predicate at the record call site. + const record = standardSchemaToJsonSchema(z.record(z.enum(['a', 'b']), z.union([z.symbol(), z.string()])), 'output'); + expect(record.required).toBeUndefined(); + }); + + test('optional-in-pipe, void, undefined-literals, and both-tolerant intersections drop from output required', () => { + const schema = z.object({ + opt: z + .string() + .optional() + .transform(async v => v ?? 'x'), + w: z.void().refine(async () => true), + l: z.literal(undefined).refine(async () => true), + m: z + .intersection(z.object({ a: z.string() }).default({ a: 'x' }), z.object({ b: z.string() }).default({ b: 'y' })) + .refine(async () => true), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `optional` grants tolerance itself and must be recognized before the + // wrapper unwind steps past it; void/undefined-literals accept a missing key + // outright; an intersection tolerates one when BOTH sides do (each default + // fills and zod merges the results). Async stages defeat the probe, so all + // must be decided structurally. + expect(result.required).toEqual(['name']); + + // Same predicate at the record call site. + const record = standardSchemaToJsonSchema( + z.record( + z.enum(['a', 'b']), + z + .string() + .optional() + .transform(async v => v ?? 'x') + ), + 'output' + ); + expect(record.required).toBeUndefined(); + }); + + test('a required field whose transform throws on undefined stays required and does not crash', async () => { + const schema = z.object({ + n: z.unknown().transform(v => (v as string).length), + c: z.custom(() => true).transform(v => (v as string).length), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `n` unwinds to the undefined-accepting `z.unknown()` leaf, so it counts as + // missing-key tolerant structurally (loosen-only). `c` unwinds to `custom` + // (no structural verdict), so the probe runs: the transform throws on + // undefined (depending on the zod version the probe throws synchronously or + // returns a rejecting Promise) — the field conservatively stays required, and + // no unhandled rejection may escape (vitest fails the run on one). + expect(result.required).toEqual(['c', 'name']); + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + test('plain z.object() output schemas do not advertise additionalProperties:false', () => { + const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output'); + + // zod validation passes unknown keys through on plain objects, so the raw + // payload may carry extras the advertised schema must not forbid. + expect(result.additionalProperties).toBeUndefined(); + }); + + test('z.strictObject() output schemas keep additionalProperties:false', () => { + const result = standardSchemaToJsonSchema(z.strictObject({ name: z.string() }), 'output'); + + // Strict objects reject extras during validation, so the promise is kept. + expect(result.additionalProperties).toBe(false); + }); +}); diff --git a/packages/core-internal/test/util/standardSchema.zodFallback.test.ts b/packages/core-internal/test/util/standardSchema.zodFallback.test.ts index f8862b08a3..666da75ab9 100644 --- a/packages/core-internal/test/util/standardSchema.zodFallback.test.ts +++ b/packages/core-internal/test/util/standardSchema.zodFallback.test.ts @@ -22,6 +22,18 @@ describe('standardSchemaToJsonSchema — zod fallback paths', () => { warn.mockRestore(); }); + it('applies the zod conversion options on the fallback path (z.date() does not throw)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const real = z.object({ when: z.date() }); + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + expect((result.properties as Record)?.when).toEqual({ type: 'string', format: 'date-time' }); + warn.mockRestore(); + }); + it('throws a clear error for zod 3 (vendor=zod, no ~standard.jsonSchema, no _zod)', () => { // zod 3.24+ reports `~standard.vendor === 'zod'` but has no `_zod` internal marker. const zod3ish = { _def: {}, '~standard': { version: 1, vendor: 'zod', validate: () => ({ value: {} }) } }; diff --git a/packages/server/test/server/toolSchemaWireShape.test.ts b/packages/server/test/server/toolSchemaWireShape.test.ts new file mode 100644 index 0000000000..7fd0d0af26 --- /dev/null +++ b/packages/server/test/server/toolSchemaWireShape.test.ts @@ -0,0 +1,115 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; +import { McpServer } from '../../src/index'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajv'; + +/** + * Regression tests for #2464: the schemas advertised in `tools/list` must describe + * the raw payloads the server actually ships — a `z.date()` field must not fail the + * whole listing, and a valid `structuredContent` must satisfy the advertised + * `outputSchema` when re-validated by a spec-compliant client. + */ + +type ResponseMessage = { id?: number; result?: Record; error?: { message: string } }; + +async function connectRawClient(server: McpServer) { + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + + const request = async (id: number, method: string, params?: Record) => { + await client.send({ jsonrpc: '2.0', id, method, params } as JSONRPCMessage); + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === id)).toBe(true)); + return responses.find(r => 'id' in r && r.id === id) as ResponseMessage; + }; + + return { request }; +} + +describe('tools/list with z.date() in a tool schema (#2464)', () => { + it('lists all tools and advertises the date field as string/date-time', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('plain', { inputSchema: { x: z.number() } }, async ({ x }) => ({ + content: [{ type: 'text' as const, text: String(x) }] + })); + server.registerTool('dated', { inputSchema: { when: z.date() } }, async () => ({ content: [] })); + + const { request } = await connectRawClient(server); + const response = (await request(2, 'tools/list')) as { + result?: { tools: Array<{ name: string; inputSchema: { properties?: Record } }> }; + error?: { message: string }; + }; + + // Before the fix, one z.date() tool failed the ENTIRE tools/list response + // ("Date cannot be represented in JSON Schema"). + expect(response.error).toBeUndefined(); + const tools = response.result?.tools ?? []; + expect(tools.map(t => t.name).sort()).toEqual(['dated', 'plain']); + expect(tools.find(t => t.name === 'dated')?.inputSchema.properties?.when).toEqual({ + type: 'string', + format: 'date-time' + }); + + await server.close(); + }); +}); + +describe('advertised outputSchema accepts the raw structuredContent the server ships (#2464)', () => { + it('a payload omitting a defaulted field and carrying an extra key satisfies the advertised schema', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool( + 'echo', + { inputSchema: {}, outputSchema: { counted: z.number().default(0), name: z.string() } }, + // Omits the defaulted `counted` and returns an extra key — both pass zod + // validation (defaults filled, extras tolerated), and the raw object ships. + async () => ({ content: [], structuredContent: { name: 'x', sneaky: true } as unknown as { name: string } }) + ); + + const { request } = await connectRawClient(server); + + const list = (await request(2, 'tools/list')) as { + result?: { tools: Array<{ outputSchema?: Record }> }; + }; + const outputSchema = list.result?.tools[0]?.outputSchema; + expect(outputSchema).toBeDefined(); + + const call = (await request(3, 'tools/call', { name: 'echo', arguments: {} })) as { + result?: { isError?: boolean; structuredContent?: unknown }; + error?: { message: string }; + }; + expect(call.error).toBeUndefined(); + expect(call.result?.isError).toBeFalsy(); + expect(call.result?.structuredContent).toEqual({ name: 'x', sneaky: true }); + + // Re-validate the shipped payload against the advertised schema, exactly as the + // SDK's own Client does. Before the fix this failed with "must have required + // property 'counted'" and "must NOT have additional properties". + const validate = new AjvJsonSchemaValidator().getValidator(outputSchema!); + expect(validate(call.result?.structuredContent)).toEqual({ + valid: true, + data: { name: 'x', sneaky: true }, + errorMessage: undefined + }); + + await server.close(); + }); +});