diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md new file mode 100644 index 0000000000..a242575799 --- /dev/null +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -0,0 +1,48 @@ +--- +'@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. And a misregistered non-object ROOT — `z.bigint()` or `z.map()` as +the whole `inputSchema`/`outputSchema` — still fails `tools/list` loudly by design, +preserving the pre-fix error instead of listing a permanently-broken tool.) +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. The same holds for required OUTPUT fields of such types: bigint results fail +JSON-RPC serialization and Map/Set values serialize as `{}`. And a degraded +object-`.catch()` node keeps `type: 'object'` for the 2025-era wrap proof even though +catch-validation does not enforce it on the raw value. +Hand-authored reference keywords disable the loosening: a `.meta()`/registry-injected +`$ref`/`$dynamicRef` beyond zod's own registry shapes (`#`, `#/$defs/`), ANY ref — +registry-shaped included — consumed under a `not`/`if`/`contains` keyword, any +`$anchor`/`$dynamicAnchor` or `$id`, any `$recursiveRef`/`$recursiveAnchor`, a non-root +`$defs`, or any hand-authored `unevaluatedProperties`/`unevaluatedItems` makes the +conversion ship the strict pre-fix-shaped emission instead — such constructs would observe +the loosening's rewrites as dangling pointers, stale anchors, polarity-inverted negations, +or stripped evaluation annotations, so those schemas keep pre-fix strictness, compilable +and working by construction.) 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..74ac71cc28 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -169,6 +169,704 @@ 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. + * - Likewise a REQUIRED OUTPUT field of a loud unrepresentable type (`z.bigint()`, + * `z.map()`, `z.set()`) lists silently as a permanently-broken tool: bigint + * results fail JSON-RPC serialization, and Map/Set values ship as `{}` garbage + * that vacuously satisfies the unconstrained `{}` advertisement. A field-level + * throw would resurrect the whole-`tools/list` outage this fix eliminates, so + * only unrepresentable output ROOTS throw (see the output epilogue). + * - The `type: 'object'` kept on degraded object-`.catch()` nodes is itself + * unenforced on the raw value: catch-validation accepts any raw, so a wrong-typed + * raw at an object-catch position ships and fails client re-validation while the + * scalar-catch spelling of the same tolerance passes. The keep is structurally + * forced by the 2025-era legacy-wrap object proof and zod's instance dedup. + * - 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. + * - Hand-authored reference keywords disable the wire-truthfulness loosening + * entirely: when the emission carries any `$ref`/`$dynamicRef` beyond zod's own + * registry shapes (`#`, `#/$defs/`), ANY ref — registry-shaped included — + * consumed under a `not`/`if`/`contains` polarity boundary, any + * `$anchor`/`$dynamicAnchor`, any `$id`, any `$recursiveRef`/`$recursiveAnchor` + * (2019-09 spellings the SDK's Ajv2020 engine still enforces), a non-root + * `$defs`, or any `unevaluatedProperties`/`unevaluatedItems` (annotation + * consumers that observe in-place loosening of their contributors as a + * tightening), the conversion ships the strict pre-#2464-shaped emission + * instead (see {@linkcode hasHandAuthoredReferenceConstructs}) — the loosen + * family relocates and deletes document positions, which such constructs + * observe as dangling pointers, stale anchors, shifted base-relative paths, or + * polarity-inverted/annotation-stripped tightenings. Those conversions keep + * pre-#2464 strictness (e.g. `additionalProperties: false` stays advertised, + * tolerant fields stay `required`), trading loosening for + * compilability-by-construction. + * - The loosen rewrite's `oneOf` → `anyOf` rename skips lexical `not`/`if`/`contains` + * positions, but a `.meta({not: {oneOf: […]}})` cloning a positive-position + * composition observes the (legitimately) renamed sibling while keeping its own + * `oneOf` — the same composition cannot read `anyOf` for its positive consumer + * and `oneOf` for a negated clone, so cross-polarity duplication of loosened + * compositions stays untruthful (reference-keyword aliasing itself disables + * loosening per the previous bullet). + */ +function zodConversionOptions( + io: 'input' | 'output', + loosened: { value: boolean }, + loosen: boolean +): Pick { + return { + unrepresentable: 'any', + override: ctx => { + const def = ctx.zodSchema._zod.def; + if ('id' in ctx.jsonSchema) { + // zod copies registry metadata (`.meta({id: 'X'})`) verbatim, emitting a + // literal draft-04 `id` keyword that Ajv v8 hard-rejects at COMPILE time + // ('NOT SUPPORTED: keyword "id", use "$id"' — strict: false does not + // help), so the SDK's own client could never validate the advertisement. + // `$ref`s are path-based (#/$defs/Name) and cannot dangle; renaming to + // `$id` would change base-URI resolution, so plain removal. + delete ctx.jsonSchema.id; + } + 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; + } + // The loosen family below is gated twice: it rewrites only OUTPUT + // advertisements, and only when the caller established (via the + // strict detection pass) that no hand-authored reference construct + // could observe the rewrites — see standardSchemaToJsonSchema. + if (io !== 'output' || !loosen) 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)) { + if (key === 'type' && ctx.jsonSchema.type === 'object') continue; + if ((key === 'anyOf' || key === 'oneOf' || key === 'allOf') && Array.isArray(ctx.jsonSchema[key])) { + // Members reduce to skeletons UNDER THEIR EMITTED KEY. An + // exactly-one `oneOf` of indistinguishable skeletons would + // reject every payload, but the rename to the honest `anyOf` + // is DEFERRED to the epilogue's `rewriteOneOfToAnyOf` — it + // always runs once the degrade set `loosened`, and unlike + // this override hook it knows the node's document path, so + // the rename records the pointer move that keeps inbound + // `$ref: '#/…/oneOf/'` aliases resolvable. + ctx.jsonSchema[key] = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + continue; + } + // Reference TARGETS and containers constrain no instance value — + // deleting them only dangles inbound `$ref`s and makes the + // advertisement uncompilable. + if (key === '$anchor' || key === '$dynamicAnchor' || key === '$id' || key === '$defs') continue; + // Only schema-carrying and enforced keywords constrain validation; + // everything else — vocabulary annotations, `x-*` extensions, custom + // `.meta()` keys — is annotation-opaque and kept. + if (SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key) || ENFORCED_JSON_SCHEMA_KEYWORDS.has(key)) { + 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 === 'array') { + // JSON.stringify turns an undefined array ELEMENT into `null` (unlike an + // undefined-valued object key, which it drops), so a tolerant element + // (`.default()`/`.prefault()`, …) may ship as null — the advertised item + // subschema must accept it. + const items = ctx.jsonSchema.items; + // Tolerance uses the same predicate as the object required-filter + // and record branches — structural walk PLUS the validate(undefined) + // probe — so probe-only-tolerant elements (e.g. a z.preprocess whose + // fn itself maps undefined to a value) get the wrap too. + if (typeof items === 'object' && items !== null && !Array.isArray(items) && fieldAcceptsMissingKey(def.element)) { + loosened.value = true; + ctx.jsonSchema.items = { anyOf: [items, { type: 'null' }] }; + } + return; + } + if (def.type === 'tuple') { + // Same wire mechanism per prefix position. The wrap REASSIGNS a + // fresh array on the node's top-level key — never writes into the + // existing one: a `.meta({prefixItems})` array is the user's + // registry-owned object (zod's Object.assign meta merge shares it + // by reference, and this hook runs before the terminal deep + // clone), so an in-place element write would permanently corrupt + // registry metadata, nest one more anyOf per conversion, and leak + // false null-tolerance into input advertisements. + const prefixItems = ctx.jsonSchema.prefixItems; + if (Array.isArray(def.items) && Array.isArray(prefixItems)) { + let wrappedAny = false; + const wrapped = prefixItems.map((emitted, index) => { + const item = index < (def.items as unknown[]).length ? (def.items as z.core.$ZodType[])[index] : undefined; + if (item !== undefined && typeof emitted === 'object' && emitted !== null && fieldAcceptsMissingKey(item)) { + wrappedAny = true; + return { anyOf: [emitted, { type: 'null' }] }; + } + return emitted as unknown; + }); + if (wrappedAny) { + loosened.value = true; + ctx.jsonSchema.prefixItems = wrapped as typeof ctx.jsonSchema.prefixItems; + } + } + // ... and for the REST element, whose emitted subschema lands under + // `items` in draft-2020-12. + const restItems = ctx.jsonSchema.items; + if ( + def.rest !== undefined && + typeof restItems === 'object' && + restItems !== null && + !Array.isArray(restItems) && + fieldAcceptsMissingKey(def.rest ?? undefined) + ) { + loosened.value = true; + ctx.jsonSchema.items = { anyOf: [restItems, { type: 'null' }] }; + } + return; + } + if ( + def.type === 'literal' && + Array.isArray(def.values) && + def.values.some(value => typeof value === 'number' && !Number.isFinite(value)) + ) { + // Non-finite number literal values (Infinity/-Infinity/NaN) serialize to + // null, and zod's own emission is the self-contradictory + // {type: 'number', const: null} that nothing satisfies — wrap it so the + // wire form validates. + loosened.value = true; + wrapConstraintsInAnyOf(ctx.jsonSchema, { type: 'null' }); + return; + } + if (def.type === 'file') { + // A File value serializes to `{}` on the wire (no enumerable own + // properties, no toJSON) while zod emits string/binary — accept the + // actual wire form too. + loosened.value = true; + wrapConstraintsInAnyOf(ctx.jsonSchema, { type: 'object' }); + return; + } + if (def.type !== 'object') return; + const isStrict = def.catchall?._zod.def.type === 'never'; + if (!isStrict && ctx.jsonSchema.additionalProperties === false) { + // A member-visible loosening like every other mutation here: two + // registry-hoisted plain objects distinguished only by their extra + // keys become mutually satisfiable, so a hand-authored exactly-one + // `oneOf` over registry refs turns reject-everything unless the + // epilogue rename fires — the flag must be set. + loosened.value = true; + 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. Also performs the + * oneOf→anyOf renames the catch degrade deferred to keep the rename in one place. + * + * The rename relocates document positions, but no reference can observe it: the + * loosen family runs only when {@linkcode hasHandAuthoredReferenceConstructs} found + * none, and zod's own registry refs (`#`, `#/$defs/`) never traverse a + * `oneOf` segment. + */ +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)) { + if (record.anyOf === undefined) { + record.anyOf = record.oneOf; + } else { + // A user `.meta({anyOf})` can coexist with the emitted `oneOf` — preserve + // conjunction semantics without clobbering it: keywords on one node + // combine with AND, so `{anyOf: members}` under `allOf` is equivalent. + // No type stamp here: the relocation destroys evidence the 2025-era + // wrap proof would read, so the epilogue decides the root stamp from + // the STRICT pre-loosen snapshot instead (byte-parity with main), and + // nested nodes never received any stamp pre-#2464. + const allOf = Array.isArray(record.allOf) ? record.allOf : (record.allOf = []); + allOf.push({ anyOf: record.oneOf }); + } + delete record.oneOf; + } + for (const [key, value] of Object.entries(record)) { + // Only schema-carrying keywords are recursed into: everything else — + // vocabulary annotations, `const`/`enum` data, `x-*` extensions, custom + // `.meta()` keys — carries user DATA whose literal `oneOf` keys must not be + // renamed. + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + // oneOf→anyOf is a loosening only in POSITIVE polarity: under `not` it + // inverts (a payload matching ≥2 members passed `not {oneOf}` but fails + // the rewritten `not {anyOf}`), under `if` it can flip which then/else + // branch applies, and under `contains` it raises the contains-count, + // tightening against a sibling `maxContains` (zod never emits `contains`, + // so skipping is regression-free). + if (key === 'not' || key === 'if' || key === 'contains') continue; + // Schema MAPS hold schemas under user-chosen names that may collide with + // annotation keywords (a property literally named `description` still + // carries a schema) — recurse into every value unconditionally. + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) rewriteOneOfToAnyOf(subschema, seen); + continue; + } + rewriteOneOfToAnyOf(value, seen); + } +} + +/** The only `$ref` shape zod's emitter produces besides bare `#`: a top-level `$defs` entry. */ +const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/; + +/** + * Whether the emitted document carries any reference construct beyond what zod's + * own emitter produces: a `$ref`/`$dynamicRef` that is not `#` or + * `#/$defs/` (the only shapes zod emits, for recursive, deduplicated, and + * registered schemas), ANY ref — registry-shaped included — consumed under a + * `not`/`if`/`contains` polarity boundary, any `$anchor`/`$dynamicAnchor`, any + * `$id`, a `$defs` container anywhere but the conversion root, or any + * `unevaluatedProperties`/`unevaluatedItems` (annotation consumers that observe + * in-place loosening of their contributors as a tightening). All of these can + * only enter an emission through `.meta()`/registry metadata — i.e. they are + * hand-authored. + * + * The wire-truthfulness loosen family relocates and deletes document positions + * (required-filter drops, catch degrades, null-tolerance element wraps, the + * oneOf→anyOf rename), which hand-authored references observe as dangling + * pointers, stale anchors, or shifted base-relative paths — an uncompilable or + * silently tightened advertisement, strictly worse than pre-#2464 strictness. + * Rather than repairing every reference form after the fact (pointer redirects, + * anchor collection, embedded-`$id` sub-documents, RFC 3986 base resolution, + * polarity-aware neutralization, …), a conversion carrying any such construct + * SKIPS the loosen family and ships the strict pre-#2464-shaped emission: + * compilable and working by construction. Registry refs are stable under every + * loosen mutation — `$defs` stays at the conversion root (the catch degrade and + * the constraint wrap both keep it), entry names are never touched, and entries + * are only mutated in place — so registry-only documents loosen freely. + * + * Position-aware: only schema-carrying keywords are inspected (data-valued + * `const`/`enum`/`default`/`examples` and annotation values may contain these key + * names as plain user data), schema-map VALUES are schemas while their keys stay + * names, and `not`/`if`/`contains` subtrees ARE inspected — their references + * break all the same. + */ +function hasHandAuthoredReferenceConstructs(document: Record): boolean { + const walk = (node: unknown, isRoot: boolean, underPolarityBoundary: boolean, seen: Set): boolean => { + if (typeof node !== 'object' || node === null || seen.has(node)) return false; + seen.add(node); + if (Array.isArray(node)) return node.some(item => walk(item, false, underPolarityBoundary, seen)); + const record = node as Record; + for (const refKey of ['$ref', '$dynamicRef'] as const) { + const value = record[refKey]; + if (value === undefined) continue; + if (typeof value !== 'string') return true; + // Registry refs are loosen-safe only for POSITIVE-polarity consumers: + // the loosen family mutates `$defs` entries IN PLACE, so a consumer + // reading the target under `not`/`if`/`contains` observes every + // loosening as a tightening the rename walk's lexical polarity skip + // cannot see through the ref indirection. + if (underPolarityBoundary) return true; + if (value !== '#' && !ZOD_REGISTRY_REF_PATTERN.test(value)) return true; + } + if (record.$anchor !== undefined || record.$dynamicAnchor !== undefined || record.$id !== undefined) return true; + // 2019-09 recursion keywords: zod never emits them, and the SDK's + // Ajv2020 engine enforces $recursiveRef in every draft mode — a negated + // `{$recursiveRef: '#'}` observes root loosening as a tightening exactly + // like its $dynamicRef successor (which the polarity clause catches). + if (record.$recursiveRef !== undefined || record.$recursiveAnchor !== undefined) return true; + if (!isRoot && record.$defs !== undefined) return true; + // `unevaluatedProperties`/`unevaluatedItems` consume the 2020-12 + // evaluation annotations their sibling (and `$ref`-mediated) subschemas + // contribute — in-place loosening of any contributor (a catch-degraded + // union member, a loosened `$defs` target) silently strips annotations + // and TIGHTENS the surviving consumer. zod never emits these keywords, + // so their presence is hand-authored by definition. + if (record.unevaluatedProperties !== undefined || record.unevaluatedItems !== undefined) return true; + for (const [key, value] of Object.entries(record)) { + if (!SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key)) continue; + // Sticky through nesting: anything beneath a negative/conditional + // keyword stays polarity-suspect (deeper `not`s only re-invert). + const childUnderBoundary = underPolarityBoundary || key === 'not' || key === 'if' || key === 'contains'; + if (SCHEMA_MAP_JSON_SCHEMA_KEYWORDS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) { + if (seen.has(value)) continue; + seen.add(value); + for (const subschema of Object.values(value as Record)) { + if (walk(subschema, false, childUnderBoundary, seen)) return true; + } + continue; + } + if (walk(value, false, childUnderBoundary, seen)) return true; + } + return false; + }; + return walk(document, true, false, new Set()); +} + +/** + * Moves a node's constraint keywords (schema-carrying + enforced) into `anyOf[0]`, + * adding `alternative` as `anyOf[1]`, while leaving annotation-opaque keys (and + * `default`) at the node top level where consumers read them — an annotation buried + * inside an applicator branch the wire payload never matches applies to nothing + * under 2020-12 annotation-collection semantics. + */ +function wrapConstraintsInAnyOf(node: Record, alternative: Record): void { + const constrained: Record = {}; + for (const key of Object.keys(node)) { + // `$defs` constrains nothing and is PATH-addressed: inbound `#/…/$defs/X` + // pointers spell out its exact position, so unlike the name-resolved + // anchors (which relocate safely inside `anyOf[0]`) it must stay put. + if (key === '$defs') continue; + if (SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS.has(key) || ENFORCED_JSON_SCHEMA_KEYWORDS.has(key)) { + constrained[key] = node[key]; + delete node[key]; + } + } + node.anyOf = [constrained, alternative]; +} + +/** + * 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'; + // Reference TARGETS constrain nothing — dropping them would dangle inbound + // $refs. `$defs` rides along verbatim: its entries are path-addressed + // (`#/…/$defs/X`), so unlike the name-resolved anchors they must stay at the + // exact position inbound pointers spell out. + for (const referenceKey of ['$anchor', '$dynamicAnchor', '$id', '$defs'] as const) { + if (source[referenceKey] !== undefined) skeleton[referenceKey] = source[referenceKey]; + } + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(source[key])) { + // Kept under the emitted key — including `oneOf`, whose skeletonized + // members are indistinguishable and would reject every payload under + // exactly-one semantics. The oneOf→anyOf rename is deferred to the + // epilogue's `rewriteOneOfToAnyOf` (the degrade that skeletonizes + // always sets `loosened`), which performs it at a known document path + // WITH pointer-move bookkeeping. + skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + } + } + return skeleton; +} + +/** + * JSON Schema keywords whose values carry SCHEMAS (directly, as arrays of schemas, or + * as name→schema maps). Every key outside this set and + * {@linkcode ENFORCED_JSON_SCHEMA_KEYWORDS} — vocabulary annotations, `x-*` vendor + * extensions, and custom `.meta()` keys zod merges verbatim — is annotation-opaque: + * 2020-12 validators ignore unknown keywords, so such keys are kept and never + * recursed into. + */ +const SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'dependentSchemas', + // Draft-07 spelling of dependentSchemas (name→schema map; the + // array-of-strings property-dependency form walks harmlessly) — Ajv2020 + // with strict:false still compiles and ENFORCES it, so the guard and the + // rename walk must see inside. + 'dependencies', + 'items', + 'prefixItems', + 'anyOf', + 'oneOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + 'additionalProperties', + 'propertyNames', + 'contains', + 'unevaluatedProperties', + 'unevaluatedItems', + // Draft-07 tail-items spelling: the SDK's cfworker provider enforces it in + // every draft mode (and collects refs inside it for resolution), so the + // guard must walk inside — parallel to `dependencies` above. + 'additionalItems' +]); + +/** Schema-map keywords among the above: their KEYS are user-chosen names, their values schemas. */ +const SCHEMA_MAP_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'dependentSchemas', + 'dependencies' +]); + +/** + * Non-schema-carrying keywords that validators ENFORCE — the `.catch()` degrade must + * delete them (catch-validation enforces nothing on the raw value), unlike unknown + * annotation-opaque keys, which are kept. + */ +const ENFORCED_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ + 'type', + 'const', + 'enum', + 'required', + 'format', + 'pattern', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'minItems', + 'maxItems', + 'uniqueItems', + 'minContains', + 'maxContains', + 'minProperties', + 'maxProperties', + 'dependentRequired', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + '$ref', + '$dynamicRef', + '$dynamicAnchor', + '$anchor', + '$id', + 'id' +]); + +/** + * 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,35 +878,96 @@ 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']; - let result: Record; - if (std.jsonSchema) { - result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET }); - } 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. - // zod 3 schemas (which also report vendor 'zod') have `_def` but not `_zod`; the SDK-bundled - // zod 4 `z.toJSONSchema()` cannot introspect them, so throw a clear error instead of crashing. - if (!('_zod' in (schema as object))) { - throw new Error( - 'Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema. ' + - 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' - ); + const loosened = { value: false }; + const convert = (zodOptions: Pick | undefined): Record => { + if (std.jsonSchema) { + return 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 + }); } - if (!warnedZodFallback) { - warnedZodFallback = true; - console.warn( - '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + - 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' - ); + 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. + // zod 3 schemas (which also report vendor 'zod') have `_def` but not `_zod`; the SDK-bundled + // zod 4 `z.toJSONSchema()` cannot introspect them, so throw a clear error instead of crashing. + if (!('_zod' in (schema as object))) { + throw new Error( + 'Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema. ' + + 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' + ); + } + if (!warnedZodFallback) { + warnedZodFallback = true; + console.warn( + '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + + 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' + ); + } + return z.toJSONSchema(schema as unknown as z.ZodType, { + target: JSON_SCHEMA_CONVERSION_TARGET, + io, + ...zodOptions + }) as Record; } - result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) 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().` ); + }; + let result: Record; + let strictRootProven: boolean | undefined; + if (options?.unrepresentable === 'throw') { + result = convert(undefined); + } else if (io !== 'output' || std.vendor !== 'zod') { + // The loosen family rewrites only zod OUTPUT advertisements — every other + // conversion runs once, with the sanitizing overrides (date rewrite, + // draft-04 `id` strip) alone for zod inputs. + result = convert(zodConversionOptions(io, loosened, false)); + } else { + // Wire-truthfulness loosening is guarded by reference-construct detection: + // first emit STRICTLY (sanitizing overrides only) and inspect the natural + // document — the authoritative view of everything the user authored, + // including constructs a loosen pass would itself delete. Hand-authored + // reference keywords ship that strict, pre-#2464-shaped emission + // (compilable and working by construction — see + // `hasHandAuthoredReferenceConstructs`); reference-free documents (and + // zod's own registry refs, stable under every mutation) convert again with + // the loosen family active. + const strict = convert(zodConversionOptions(io, loosened, false)); + if (hasHandAuthoredReferenceConstructs(strict)) { + result = strict; + } else { + // The 2025-era wrap-stamp decision must match main, which read the RAW + // emission: loosen mutations can destroy proof-relevant keys (the + // allOf-push relocates a root `oneOf`; the catch degrade deletes + // meta-authored `required`/`properties`) or expose ones main's + // first-present-key-wins rule never read — flipping the SEP-2106 + // legacy wrap either direction on an unrelated trigger. Snapshot the + // proof on the strict emission; the output epilogue consults it + // instead of the post-loosen document. + strictRootProven = isProvablyObjectShapedRoot(strict); + result = convert(zodConversionOptions(io, loosened, true)); + } + } + if (io === 'output' && loosened.value) { + // Exactly-one semantics cannot survive member loosening: once ANY loosen + // mutation fired anywhere in this conversion — a catch degrade, the + // required-filter, or the additionalProperties drop — `oneOf` members + // (zod's discriminated-union emission, or a hand-authored oneOf over + // registry refs) may have become mutually satisfiable, and Ajv would + // reject every payload with "must match exactly one schema in oneOf". + // Rewrite to the honest `anyOf` (loosen-only; wrap-neutral because the + // 2025-era stamp decision reads the strict pre-loosen snapshot; no + // reference can observe the rename — the guard above vouched for it). + rewriteOneOfToAnyOf(result); } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or @@ -222,8 +981,39 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in // as-is — stamping there would be self-contradictory. Anything that does not end up // `type:'object'` is wrapped as `{type:'object', properties:{result:…}}` by the 2025 // codec's legacy projection (see `wire/rev2025-11-25/legacyWrap.ts`). + // Loudness parity BEFORE the explicit-type early return: a loud-valued + // literal root (bigint, or undefined filtered from the value list) emits an + // explicit type under `unrepresentable: 'any'`, yet no such result can ever + // ride JSON truthfully — pre-#2464 these threw loudly. + if (isLoudLiteralOutputRoot(schema)) { + throw new Error( + `MCP tool and prompt schemas must describe objects (got a non-object literal schema). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } if (result.type !== undefined) return result; - return isProvablyObjectShapedRoot(result) ? { type: 'object', ...result } : result; + // Loudness parity for misregistered OUTPUT roots too: an unrepresentable + // non-object root (z.bigint(), z.map(), unions of them) threw at tools/list + // pre-#2464 and must keep doing so — post-degrade it would list silently as + // a permanently-broken tool (bigint results even fail JSON-RPC + // serialization; Map/Set ship `{}` garbage). Representable non-object roots + // (legal per SEP-2106) carry an explicit `type` and returned above; quiet + // shapes keep listing. + const outputVerdict = nonObjectTypelessRootVerdict(schema, 'output'); + if (outputVerdict !== undefined && outputVerdict.loud) { + throw new Error( + `MCP tool and prompt schemas must describe objects (got a non-object ${outputVerdict.type} schema). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } + // The stamp runs AFTER the guard: a loud conjunct (e.g. + // `z.intersection(z.object(...), z.bigint())`) must throw even when an object + // conjunct could prove the root. When a loosen pass ran, the proof reads the + // STRICT pre-loosen snapshot — byte-parity with main, immune to loosen + // mutations of proof-relevant keys; otherwise (guard-shipped strict result, + // non-zod vendors) the document itself is the un-mutated emission. + if (strictRootProven ?? isProvablyObjectShapedRoot(result)) return { type: 'object', ...result }; + return result; } if (result.type !== undefined && result.type !== 'object') { throw new Error( @@ -231,9 +1021,362 @@ 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, 'input'); + // 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 }; } +/** + * Whether an OUTPUT root unwinds (through transparent wrappers, lazies, and pipe OUT + * sides) to a literal with loud values (bigint, or `undefined` beside representable + * co-values that zod filters out). Such emissions carry an explicit `type` under + * `unrepresentable: 'any'` — bypassing the typeless-root guard via the explicit-type + * early return — yet the raw values server-side validation accepts can never ride + * JSON truthfully. Pre-#2464 all of them threw loudly. + */ +function isLoudLiteralOutputRoot(schema: unknown, ancestors: ReadonlySet = new Set()): boolean { + if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return false; + const def = ( + schema as { + _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; out?: unknown; values?: unknown } }; + } + )._zod?.def; + if (def === undefined || typeof def.type !== 'string') return false; + const path = new Set(ancestors); + path.add(schema); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return isLoudLiteralOutputRoot((def.getter as () => unknown)(), path); + } catch { + return false; + } + } + if (def.type === 'pipe') { + // Output conversion processes the OUT side; a bare transform there has no + // literal of its own, so fall back to the IN side (mirroring the verdict walk). + if (def.out !== undefined) { + if (isLoudLiteralOutputRoot(def.out, path)) return true; + const outDef = (def.out as { _zod?: { def?: { type?: string } } })._zod?.def; + if (outDef?.type === 'transform' && def.in !== undefined) { + return isLoudLiteralOutputRoot(def.in, path); + } + } + return false; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return isLoudLiteralOutputRoot(def.innerType, path); + } + return def.type === 'literal' && nonObjectLiteralLoudness(def.values) === 'loud'; +} + +/** + * 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 or + * bigint values) and must keep throwing; `quiet` shapes (non-finite number and + * symbol literals, which zod silently emitted without a conversion error) 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. + * + * `atIntersection` threads intersection context through the recursion: an + * intersection value must satisfy BOTH sides, so a date member reached anywhere + * under an intersection side — however deeply nested in unions/wrappers/pipes — + * turns that side loud on the output path (every date-containing intersection + * threw pre-#2464), while the same date stays quiet as a root or plain union + * member (the date override makes those emissions wire-truthful). The + * may-be-object bail keeps this satisfiability-aware: a union with an + * `undefined`-verdict member (e.g. `z.union([z.date(), z.object({…})])`) may be + * satisfiable against an object conjunct and stays accepted. + */ +function nonObjectTypelessRootVerdict( + schema: unknown, + io: 'input' | 'output', + ancestors: ReadonlySet = new Set(), + atIntersection = false +): { type: string; loud: boolean; loudInside?: 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)(), io, path, atIntersection); + } catch { + return undefined; + } + } + if (def.type === 'pipe') { + // Loudness parity must anchor to the side zod's conversion actually + // processed: input conversion reads `def.in`, output conversion `def.out` — + // pre-#2464, `z.date().transform(d => d.toISOString()).pipe(z.string().nullable())` + // converted fine on the output path (only its OUT side was visited). + const primary = io === 'output' ? def.out : def.in; + const secondary = io === 'output' ? def.in : def.out; + if (primary !== undefined) { + const verdict = nonObjectTypelessRootVerdict(primary, io, path, atIntersection); + if (verdict !== undefined) return verdict; + // A bare transform on the processed side has no verdict of its own — the + // real schema sits on the other side (`z.preprocess(fn, inner)` on input; + // on output, zod threw 'Transforms cannot be represented' pre-#2464, so + // consulting `def.in` keeps genuinely-unrepresentable inputs loud while + // representable ones degrade gracefully). + const primaryDef = (primary as { _zod?: { def?: { type?: string } } })._zod?.def; + if (primaryDef?.type === 'transform' && secondary !== undefined) { + return nonObjectTypelessRootVerdict(secondary, io, path, atIntersection); + } + } + return undefined; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return nonObjectTypelessRootVerdict(def.innerType, io, path, atIntersection); + } + if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { + const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, io, path, atIntersection)); + // A may-be-object member (z.object, z.any, z.custom, … — `undefined` + // verdicts — or a nested may-be-object composition) can make the union + // satisfiable in ANY position, so the union reports the quiet + // 'mayBeObject' verdict — but it CARRIES the members' inner loudness: + // an intersection sibling that is provably non-object kills the object + // possibility, and only then does the inner loudness surface + // (`z.union([z.date(), z.object({…})])` against an OBJECT conjunct keeps + // listing; against an ARRAY conjunct it threw pre-#2464 and keeps + // throwing). Representable non-object members return DEFINED quiet + // verdicts and do not trigger this branch. + if (verdicts.includes(undefined) || verdicts.some(verdict => verdict?.type === 'mayBeObject')) { + const loudInside = verdicts.some(verdict => verdict !== undefined && (verdict.loud || verdict.loudInside === true)); + return { type: 'mayBeObject', loud: false, loudInside }; + } + const satisfiable = verdicts.some(verdict => verdict?.type === 'representable'); + // Union semantics discharge loudness OUTSIDE intersections: one + // representable member makes the whole union a working schema + // (`z.union([z.date(), z.string()])` accepts strings on input — the date + // member is redundant, not fatal), preserving the established + // representable-member-keeps-the-composition-accepted policy. AT an + // intersection nothing representable can satisfy the object conjunct, so + // a loud member's verdict stands. + const loud = verdicts.some(verdict => verdict?.loud === true) && (atIntersection || !satisfiable); + // A satisfiable union is itself representable-satisfiable for OUTER + // compositions (`z.union([z.date(), z.union([z.string(), z.number()])])` + // must discharge exactly like its flattened spelling). + return { type: satisfiable ? 'representable' : 'union', loud }; + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + // Sides recurse in intersection context: the value must satisfy BOTH + // sides, so a date reached anywhere under either side (directly, or nested + // in unions whose every co-member is provably non-object) is fatal on the + // output path and reports loud from the date leaf itself. + const left = nonObjectTypelessRootVerdict(def.left, io, path, true); + const right = nonObjectTypelessRootVerdict(def.right, io, path, true); + const leftMayBeObject = left === undefined || left.type === 'mayBeObject'; + const rightMayBeObject = right === undefined || right.type === 'mayBeObject'; + if (leftMayBeObject && rightMayBeObject) { + if (left === undefined && right === undefined) return undefined; + // Both sides may be object, so the intersection may be satisfiable — + // quiet here, but the inner loudness rides along for an OUTER + // provably-non-object conjunct to surface. + return { + type: 'mayBeObject', + loud: false, + loudInside: left?.loudInside === true || right?.loudInside === true + }; + } + if (leftMayBeObject || rightMayBeObject) { + // One side is provably non-object: the other side's object possibility + // is dead — no value satisfies both — so its carried inner loudness + // surfaces (`z.intersection(z.array(…), z.union([z.date(), z.object(…)]))` + // threw pre-#2464). + const provablyNonObject = leftMayBeObject ? right! : left!; + const mayBeObject = leftMayBeObject ? left : right; + return { type: 'intersection', loud: provablyNonObject.loud || mayBeObject?.loudInside === true }; + } + return { type: 'intersection', loud: left!.loud || right!.loud }; + } + if (def.type === 'literal') { + // The verdict stays DEFINED whatever the values: literal values are + // primitives, so the node can never satisfy an object conjunct. A + // representable value (string/finite number/boolean/null) wins over quiet + // ones and marks the literal satisfiable. + const loudness = nonObjectLiteralLoudness(def.values); + if (loudness === undefined) return { type: 'representable', loud: false }; + return { type: 'literal', loud: loudness === 'loud' }; + } + if (def.type === 'date') { + // The date override rewrites every date node to its true wire form + // (string/date-time), so a date-rooted OUTPUT emission is wire-truthful and + // must keep listing (`z.date().nullable()` is a working 'timestamp or null' + // tool); on INPUT no JSON payload satisfies raw z.date() validation, and at + // an intersection no value satisfies both a Date side and the other side — + // every date-containing intersection threw pre-#2464, so parity keeps + // those loud. + return { type: 'date', loud: io === 'input' || atIntersection }; + } + if (def.type === 'never') { + // z.never() matches no value: it can never make a union satisfiable or + // object-shaped. Quiet, though — bare/all-never shapes emitted `{not: {}}` + // without throwing pre-#2464 and must keep listing; the verdict only turns a + // union loud when a loud co-member (e.g. a bigint) is present, restoring the + // pre-#2464 throw for those. + return { type: 'never', loud: false }; + } + if (def.type === 'file') { + // A File can never ride JSON: it cannot make a union JSON-satisfiable. Quiet — + // bare z.file() roots throw via the explicit-type guard (string/binary + // emission) and file+representable shapes listed silently pre-#2464; only a + // loud co-member restores the pre-#2464 throw. + return { type: 'file', loud: false }; + } + if (REPRESENTABLE_NON_OBJECT_ZOD_DEF_TYPES.has(def.type)) { + // Representable and provably non-object: quiet in every position (these + // all converted and listed pre-#2464), but DEFINED — unlike the + // may-be-object `undefined` verdict — so a union pairing one with a date + // cannot launder the intersection date-parity rule through the bail above + // (`z.union([z.date(), z.string()])` against an object conjunct admits no + // satisfying value and threw pre-#2464). + return { type: 'representable', loud: false }; + } + return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; +} + +/** + * Zod def types that always emit a representable NON-OBJECT JSON type. Their + * verdicts are quiet but DEFINED: `undefined` is reserved for may-be-object + * shapes (`z.object`, `z.any`, `z.custom`, unrecognized defs), which is what the + * union bail keys on to keep possibly-satisfiable intersections listing. + */ +const REPRESENTABLE_NON_OBJECT_ZOD_DEF_TYPES: ReadonlySet = new Set([ + 'string', + 'number', + 'boolean', + 'null', + 'enum', + 'template_literal', + // A JSON array is never a JSON object (z.object rejects arrays at parse + // time), so array/tuple members are provably non-object exactly like the + // primitive types above. + 'array', + 'tuple' +]); + +/** + * 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) — such roots must keep failing loudly. `'quiet'`: non-finite + * numbers (`Infinity`/`-Infinity`/`NaN`, silently emitted as + * `{type: 'number', const: null}`) and symbol values (silently emitted as + * `{type: 'symbol'}`) — 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 quiet = false; + for (const value of values) { + if (value === null) { + representable = true; + continue; + } + const valueType = typeof value; + if (valueType === 'undefined' || valueType === 'bigint') { + // These literal VALUES threw pre-#2464 regardless of co-values. + return 'loud'; + } + if (valueType === 'symbol') { + // Not JSON-satisfiable, but zod silently emitted {type: 'symbol'} for it + // pre-#2464 — quiet, like non-finite numbers. + quiet = true; + continue; + } + if (valueType === 'number' && !Number.isFinite(value as number)) { + quiet = true; + continue; + } + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { + representable = true; + continue; + } + return 'loud'; // unknown value kind — conservative + } + if (representable) return undefined; + return quiet ? 'quiet' : undefined; +} + +/** + * Zod def types that are genuinely unrepresentable in JSON Schema (they degrade to a + * typeless `{}` under `unrepresentable: 'any'`) and whose values can never be a JSON + * object — the root guards must keep rejecting roots and compositions built from + * them. `custom` and bare `transform` are deliberately excluded (they can + * legitimately accept objects); `date` gets a dedicated io-aware branch (loud on + * input, quiet on output where the override makes it wire-truthful); 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' +]); + +/** 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 @@ -245,16 +1388,22 @@ function isProvablyObjectShapedRoot(schema: Record): boolean { if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { return true; } + const isObjectMember = (member: unknown): boolean => + member !== null && + typeof member === 'object' && + ((member as Record).type === 'object' || isProvablyObjectShapedRoot(member as Record)); + // FIRST-PRESENT-KEY-WINS with EVERY-member semantics, matching the pre-#2464 + // stamp/wrap decision for untouched emissions: a multi-key root whose first + // present composition key has a non-object member (e.g. an anyOf-emitting + // nullable union carrying a user `.meta({allOf: [{type: 'object'}]})`) stayed + // typeless and 2025-era-wrapped on main, so a later key must not prove what + // the first cannot. The loosen rewrite's allOf-push does not rely on this + // proof seeing its relocated conjunct: it stamps `type: 'object'` itself, and + // its internal proof argument carries only `anyOf`. for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { const members = schema[key]; - if (Array.isArray(members) && members.length > 0) { - return members.every( - m => - m !== null && - typeof m === 'object' && - ((m as Record).type === 'object' || isProvablyObjectShapedRoot(m as Record)) - ); - } + if (!Array.isArray(members) || members.length === 0) continue; + return members.every(member => isObjectMember(member)); } return false; } 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..f6bcb2ed6c 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1,6 +1,9 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; +import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider'; +import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { @@ -40,3 +43,1603 @@ 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']); + // zod copies the registry id verbatim as a draft-04 `id` keyword, which Ajv v8 + // hard-rejects at COMPILE time — the advertisement must stay compilable by the + // SDK's own client-side validator. + expect((result.$defs as Record>).StandardSchemaTestCounted!.id).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ name: 'x' }).valid).toBe(true); + }); + + 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 of plain objects renames to anyOf (additionalProperties drop is loosening)', () => { + // The members' additionalProperties: false drop is member-visible + // loosening (two objects distinguished only by their extra keys become + // mutually satisfiable), so the rename gate must fire. Harmless for zod + // DUs — the discriminator consts keep members exclusive under anyOf. + 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(result.oneOf).toBeUndefined(); + expect(Array.isArray(result.anyOf)).toBe(true); + expect(result.type).toBe('object'); // wrap parity: the strict snapshot proves the oneOf + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ t: 'a', x: 'v' }).valid).toBe(true); + expect(validate({ t: 'c' }).valid).toBe(false); // discriminators still enforce + // STRICT objects keep additionalProperties: false — nothing loosens, the + // exactly-one oneOf survives. + const strictDu = standardSchemaToJsonSchema( + z.discriminatedUnion('t', [z.strictObject({ t: z.literal('a'), x: z.string() }), z.strictObject({ t: z.literal('b') })]), + 'output' + ); + expect(Array.isArray(strictDu.oneOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(strictDu)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + + test('tolerant array/tuple elements also accept null in output schemas', () => { + // JSON.stringify turns an undefined array ELEMENT into null (it drops + // undefined-valued object keys), so the raw payload the server validates and + // ships ([1, undefined, 3]) reaches the wire as [1, null, 3]. + const schema = z.object({ + a: z.array(z.number().default(0)), + t: z.tuple([z.string().default('x'), z.number()]), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect(new AjvJsonSchemaValidator().getValidator(result)({ a: [1, null, 3], t: [null, 1], name: 'n' }).valid).toBe(true); + // Non-tolerant elements keep their strict subschema. + const plain = standardSchemaToJsonSchema(z.object({ a: z.array(z.number()) }), 'output'); + expect((plain.properties as Record>).a!.items).toEqual({ type: 'number' }); + }); + + test('z.never() union members are quiet non-object verdicts', () => { + // z.never() matches no value: it cannot make a union satisfiable, so a loud + // co-member must keep the pre-#2464 throw ... + expect(() => standardSchemaToJsonSchema(z.union([z.never(), z.bigint()]), 'input')).toThrow(/must describe objects/); + // ... while quiet shapes (which converted silently pre-#2464) keep listing. + expect(standardSchemaToJsonSchema(z.never(), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.never(), z.string()]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.union([z.never(), z.never()]), 'input').type).toBe('object'); + }); + + test('unrepresentable non-object OUTPUT roots throw too (loudness parity)', () => { + // These threw at tools/list pre-#2464; post-degrade they would list silently + // as permanently-broken tools (bigint results even fail JSON-RPC + // serialization; Map/Set ship {} garbage). + expect(() => standardSchemaToJsonSchema(z.bigint(), 'output')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.union([z.bigint(), z.symbol()]), 'output')).toThrow(/must describe objects/); + // Representable non-object output roots are legal per SEP-2106 ... + expect(standardSchemaToJsonSchema(z.string(), 'output').type).toBe('string'); + expect(standardSchemaToJsonSchema(z.array(z.number()), 'output').type).toBe('array'); + // ... and quiet typeless shapes keep listing. + expect(standardSchemaToJsonSchema(z.never(), 'output').type).toBeUndefined(); + }); + + test('output pipes anchor loudness to their OUT side (codec roots keep listing)', () => { + // Pre-#2464, output conversion only visited a pipe's OUT side — these codecs + // (raw Date/bigint in, representable declared output) listed and worked + // wire-truthfully; anchoring the verdict to def.in threw 'non-object date' + // and killed the whole tools/list. + const dateCodec = z + .date() + .transform(d => d.toISOString()) + .pipe(z.string().nullable() as unknown as z.ZodType); + expect(standardSchemaToJsonSchema(dateCodec, 'output').anyOf).toBeDefined(); + const bigintCodec = z + .bigint() + .transform(String) + .pipe(z.string().nullable() as unknown as z.ZodType); + expect(standardSchemaToJsonSchema(bigintCodec, 'output').anyOf).toBeDefined(); + // A bare-transform OUT side falls back to def.in: pre-#2464 zod threw + // 'Transforms cannot be represented' on output, so genuinely-unrepresentable + // inputs stay loud ... + expect(() => + standardSchemaToJsonSchema( + z.bigint().transform(x => Number(x)), + 'output' + ) + ).toThrow(/must describe objects/); + // ... and input-path pipe semantics are unchanged. + expect(() => + standardSchemaToJsonSchema( + z.bigint().transform(x => Number(x)), + 'input' + ) + ).toThrow(/must describe objects/); + }); + + test('quiet literal values (symbols) keep compositions listing', () => { + // Pre-#2464, z.literal(Symbol) silently emitted {type: 'symbol'} — only + // undefined/bigint literal VALUES threw. These compositions listed pre-#2464. + expect( + standardSchemaToJsonSchema( + z.union([z.literal(Symbol('a') as unknown as string), z.literal(Symbol('b') as unknown as string)]), + 'input' + ).type + ).toBe('object'); + expect( + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.literal(Symbol('x') as unknown as string)), 'input') + .type + ).toBe('object'); + // A representable co-value keeps mixed lists satisfiable (no early loud return). + expect( + standardSchemaToJsonSchema(z.union([z.literal(['x', Symbol('s') as unknown as string]), z.literal(['y', 1])]), 'input').type + ).toBe('object'); + // Loud co-members and loud literal values still throw ... + expect(() => standardSchemaToJsonSchema(z.union([z.literal(Symbol('a') as unknown as string), z.bigint()]), 'input')).toThrow( + /must describe objects/ + ); + // ... and a bare symbol literal root keeps throwing via the explicit-type guard. + expect(() => standardSchemaToJsonSchema(z.literal(Symbol('x') as unknown as string), 'input')).toThrow(/got type/); + }); + + test('the anyOf constraint wrap keeps annotations at the node top level', () => { + const schema = z.object({ + f: z.file().describe('binary upload'), + inf: z.literal(Infinity).describe('sentinel'), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + const properties = result.properties as Record>; + + // An annotation buried inside an applicator branch the wire payload never + // matches applies to nothing — consumers read the top-level description. + expect(properties.f!.description).toBe('binary upload'); + expect(properties.inf!.description).toBe('sentinel'); + // The wire forms still validate. + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ f: {}, inf: null, name: 'n' }).valid).toBe(true); + }); + + test('z.file() union members are quiet non-object verdicts', () => { + // A File can never ride JSON, so a loud co-member restores the pre-#2464 throw ... + expect(() => standardSchemaToJsonSchema(z.union([z.file(), z.bigint()]), 'input')).toThrow(/must describe objects/); + // ... while quiet pre-#2464 shapes keep listing (or throwing via the + // explicit-type guard, for the bare string/binary emission). + expect(standardSchemaToJsonSchema(z.union([z.file(), z.string()]), 'input').type).toBe('object'); + expect(standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.file()), 'input').type).toBe('object'); + expect(() => standardSchemaToJsonSchema(z.file(), 'input')).toThrow(/got type/); + }); + + test('the oneOf rewrite reaches schemas under keyword-named properties', () => { + // Keys inside `properties` are user names, not keywords — a property + // literally named `description` still carries a schema that the loosen + // rewrite must reach. + const schema = z.object({ + description: 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() }) + ]), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const description = (result.properties as Record>).description!; + expect(description.oneOf).toBeUndefined(); + expect(Array.isArray(description.anyOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(result)({ description: { t: 'a', x: 'v' }, name: 'n' }).valid).toBe(true); + }); + + test('tolerant tuple REST elements and non-finite literals accept their wire forms', () => { + const schema = z.object({ + t: z.tuple([z.string()], z.number().default(0)), + inf: z.literal(Infinity), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A tuple rest element lands under `items`; an undefined rest element ships + // as null. Non-finite literal values also serialize to null, and zod's own + // emission ({type: 'number', const: null}) satisfies nothing. + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: ['a', 1, null], inf: null, name: 'n' }).valid).toBe(true); + }); + + test('the oneOf rewrite does not descend into annotation values', () => { + const schema = z.object({ + cfg: z.object({ oneOf: z.array(z.number()) }).default({ oneOf: [1, 2] }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // `default` carries user DATA — its literal `oneOf` key must survive. + const cfg = (result.properties as Record>).cfg!; + expect(cfg.default).toEqual({ oneOf: [1, 2] }); + }); + + test('the oneOf rewrite preserves a coexisting user anyOf via allOf', () => { + // zod merges .meta({anyOf}) verbatim beside the emitted oneOf — bailing there + // left a reject-everything oneOf after the members were loosened. + 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() }) + ]) + .meta({ anyOf: [{ type: 'object' }] }); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(result.oneOf).toBeUndefined(); + expect(result.anyOf).toEqual([{ type: 'object' }]); // the user's anyOf, unclobbered + const allOf = result.allOf as Array<{ anyOf: Array> }>; + expect(allOf).toHaveLength(1); + expect(allOf[0]!.anyOf.map(member => member.type)).toEqual(['object', 'object']); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + + test('the oneOf rewrite skips negated and conditional positions', () => { + // Under `not`, oneOf→anyOf inverts polarity and TIGHTENS: {v: 4} matches + // both members, so it passed `not {oneOf}` pre-#2464 but would fail the + // rewritten `not {anyOf}`. + const schema = z.object({ + v: z.number().meta({ not: { oneOf: [{ type: 'integer' }, { multipleOf: 2 }] } }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).v!.not).toEqual({ + oneOf: [{ type: 'integer' }, { multipleOf: 2 }] + }); + expect(new AjvJsonSchemaValidator().getValidator(result)({ v: 4, counted: 1, name: 'n' }).valid).toBe(true); + }); + + test('the oneOf rewrite skips contains (count-tightening under maxContains)', () => { + // Per element, anyOf matches a superset of oneOf, so the contains-count can + // only rise — under a sibling maxContains the rename would TIGHTEN: 4 and 6 + // match both branches (excluded by oneOf, counted by anyOf). + const schema = z.object({ + arr: z.array(z.number()).meta({ contains: { oneOf: [{ type: 'integer' }, { multipleOf: 2 }] }, maxContains: 2 }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).arr!.contains).toEqual({ + oneOf: [{ type: 'integer' }, { multipleOf: 2 }] + }); + expect(new AjvJsonSchemaValidator().getValidator(result)({ arr: [3, 5, 4, 6], counted: 1, name: 'n' }).valid).toBe(true); + }); + + test('positional pointer aliases ship the strict emission (reference guard)', () => { + // A hand-authored positional $ref could observe the loosen family's + // relocations (the oneOf→anyOf rename here) as a dangling pointer — the + // guard skips the loosen family instead, shipping the raw pre-#2464 + // emission: compilable and working by construction, strict. + 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 schema = z.object({ + du, + alias: z.unknown().meta({ $ref: '#/properties/du/oneOf/0' }), + dynAlias: z.unknown().optional().meta({ $dynamicRef: '#/properties/du/oneOf/1' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // Byte-equal to zod's own emission (nothing here for the date/id + // sanitizers to touch). + expect(result).toEqual(z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'output', unrepresentable: 'any' })); + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeDefined(); + expect(properties.alias!.$ref).toBe('#/properties/du/oneOf/0'); + expect(properties.dynAlias!.$dynamicRef).toBe('#/properties/du/oneOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, alias: { t: 'a' }, name: 'n' }).valid).toBe(true); + }); + + test('a pointer alias beside a user anyOf ships strict (reference guard)', () => { + 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() }) + ]) + .meta({ anyOf: [{ type: 'object' }] }); + const schema = z.object({ + du, + alias: z.unknown().meta({ $ref: '#/properties/du/oneOf/1' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeDefined(); + expect(properties.du!.anyOf).toEqual([{ type: 'object' }]); // the user's, unmoved + expect(properties.alias!.$ref).toBe('#/properties/du/oneOf/1'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'b' }, alias: { t: 'b' }, name: 'n' }).valid).toBe(true); + }); + + test('pointers through an untouched oneOf stay put (no-loosening control)', () => { + const du = z.discriminatedUnion('t', [z.object({ t: z.literal('a') }), z.object({ t: z.literal('b') })]); + const schema = z.object({ + du, + alias: z.string().optional().meta({ $ref: '#/properties/du/oneOf/0' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeDefined(); + expect(properties.alias!.$ref).toBe('#/properties/du/oneOf/0'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, name: 'n' }).valid).toBe(true); + }); + + test('catch nodes with pointer aliases ship strict (reference guard)', () => { + // Pre-guard, the catch degrade would have deleted the members' constraints + // and renamed the oneOf out from under the alias. + const schema = z.object({ + cfg: 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' }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/oneOf/0' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.cfg!.oneOf).toBeDefined(); // no degrade, no rename + expect(properties.alias!.$ref).toBe('#/properties/cfg/oneOf/0'); + expect( + new AjvJsonSchemaValidator().getValidator(result)({ cfg: { t: 'a', x: 'v' }, alias: { t: 'a', x: 'w' }, name: 'n' }).valid + ).toBe(true); + }); + + test('array and tuple element aliases ship strict — no null wrap (reference guard)', () => { + const schema = z.object({ + arr: z.array(z.object({ x: z.string() }).optional()), + t: z.tuple([z.object({ x: z.string() }).optional(), z.string()], z.object({ y: z.number() }).optional()), + itemsAlias: z.unknown().meta({ $ref: '#/properties/arr/items/properties/x' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect((properties.arr!.items as Record).anyOf).toBeUndefined(); // strict: no null tolerance advertised + expect(properties.itemsAlias!.$ref).toBe('#/properties/arr/items/properties/x'); + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ arr: [{ x: 'v' }], t: [{ x: 'v' }, 's'], itemsAlias: 'w', name: 'n' }).valid).toBe(true); + // Pre-#2464 strictness kept: a null element is rejected by the + // advertisement even though the raw payload may carry one. + expect(validate({ arr: [{ x: 'v' }, null], t: [{ x: 'v' }, 's'], itemsAlias: 'w', name: 'n' }).valid).toBe(false); + }); + + test('refs into catch subtrees stay resolvable under the guard', () => { + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/properties/q' }), + keptAlias: z.unknown().meta({ $ref: '#/properties/name' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect((properties.cfg!.properties as Record).q).toEqual({ type: 'string' }); // no degrade + expect(properties.alias!.$ref).toBe('#/properties/cfg/properties/q'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ cfg: { q: 'x' }, alias: 'y', keptAlias: 'k', name: 'n' }).valid).toBe( + true + ); + }); + + test('anchors and anchor-form refs ship strict (reference guard)', () => { + const schema = z.object({ + cfg: z.object({ q: z.string().meta({ $anchor: 'innerA' }) }).catch({ q: 'd' }), + skel: z.union([z.object({ q: z.string().meta({ $anchor: 'deepA' }) }), z.object({ w: z.number() })]).catch({ q: 'd' }), + live: z.object({ q: z.string() }).meta({ $dynamicAnchor: 'liveD' }), + aliasA: z.unknown().meta({ $ref: '#innerA' }), + aliasB: z.unknown().meta({ $ref: '#deepA' }), + aliasC: z.unknown().meta({ $dynamicRef: '#liveD' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.aliasA!.$ref).toBe('#innerA'); // the anchors survive — no degrade ran + expect(properties.aliasB!.$ref).toBe('#deepA'); + expect( + new AjvJsonSchemaValidator().getValidator(result)({ + cfg: { q: 'x' }, + skel: { q: 'y' }, + live: { q: 'z' }, + aliasA: 'a', + aliasB: 'b', + aliasC: { q: 'c' }, + name: 'n' + }).valid + ).toBe(true); + }); + + test('embedded-$id resources ship strict (reference guard)', () => { + // Outside-in base-addressed and inside base-relative refs both resolve + // against the untouched emission — including inside the resource, where + // the rename walk used to fire $id-blind. + const schema = z.object({ + du: z + .discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({}).meta({ $ref: '#/oneOf/0' }) + ]) + .meta({ $id: 'https://example.com/du' }), + outer: z.unknown().meta({ $ref: 'https://example.com/du#/oneOf/0' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.du!.oneOf).toBeDefined(); // rename skipped inside the resource too + expect(properties.outer!.$ref).toBe('https://example.com/du#/oneOf/0'); + expect(new AjvJsonSchemaValidator().getValidator(result)({ du: { t: 'a' }, outer: { t: 'a' }, name: 'n' }).valid).toBe(true); + }); + + test('refs under negative and conditional keywords ship strict (reference guard)', () => { + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + negated: z.number().meta({ not: { $anchor: 'A', $ref: '#/properties/cfg/properties/q' } }), + early: z.unknown().meta({ $ref: '#/properties/negated/not' }), + arr: z.unknown().meta({ + type: 'array', + prefixItems: [{ type: 'string' }], + contains: { $ref: '#/properties/cfg/properties/q' }, + unevaluatedItems: false + }), + conditional: z.number().meta({ if: { $ref: '#/properties/cfg/properties/q' }, then: { multipleOf: 2 } }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.negated!.not).toBeDefined(); // conjuncts survive — their refs resolve + expect(properties.arr!.contains).toBeDefined(); + expect(properties.arr!.unevaluatedItems).toBe(false); + expect(properties.conditional!.if).toBeDefined(); + // Pre-PR-valid payload: `not` never matches a number, `contains` + // evaluates 'tail' for unevaluatedItems, `if` never fires its then-branch. + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ cfg: { q: 'x' }, negated: 4, early: 'e', arr: ['head', 'tail'], conditional: 3, name: 'n' }).valid).toBe(true); + }); + + test('probe-only-tolerant array and tuple elements get the null wrap', () => { + // The preprocess fn itself maps undefined to a value, so tolerance is + // invisible structurally and only the validate(undefined) probe sees it — + // the wrap sites must use the same predicate as the object/record + // branches. + const element = z.preprocess((value: unknown) => value ?? 0, z.number()); + const result = standardSchemaToJsonSchema( + z.object({ a: z.array(element), t: z.tuple([element, z.string()], element), name: z.string() }), + 'output' + ); + + const properties = result.properties as Record>; + expect((properties.a!.items as Record).anyOf).toEqual([{ type: 'number' }, { type: 'null' }]); + expect((properties.t!.prefixItems as Array>)[0]!.anyOf).toEqual([{ type: 'number' }, { type: 'null' }]); + expect((properties.t!.items as Record).anyOf).toEqual([{ type: 'number' }, { type: 'null' }]); + expect(new AjvJsonSchemaValidator().getValidator(result)({ a: [1, null, 3], t: [null, 's', null], name: 'n' }).valid).toBe(true); + }); + + test('untouched conversions keep a dangling ref (neutralization is loosen-scoped)', () => { + // No degrade fired here: a ref the user broke themselves failed compile + // identically pre-#2464 and is not the loosen machinery's to repair. + const result = standardSchemaToJsonSchema(z.object({ alias: z.string().meta({ $ref: '#/nowhere' }), name: z.string() }), 'output'); + + expect((result.properties as Record>).alias!.$ref).toBe('#/nowhere'); + }); + + test('non-root $defs ships strict (reference guard)', () => { + const schema = z.object({ + cfg: z + .union([z.object({ v: z.string() }).meta({ $defs: { X: { type: 'string' } } }), z.object({ w: z.number() })]) + .catch({ v: 'd' }), + f: z.file().meta({ $defs: { Y: { type: 'string' } } }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/anyOf/0/$defs/X' }), + wrapAlias: z.unknown().meta({ $ref: '#/properties/f/$defs/Y' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect((properties.cfg!.anyOf as Array>)[0]!.$defs).toEqual({ X: { type: 'string' } }); + expect(properties.f!.$defs).toEqual({ Y: { type: 'string' } }); + expect(properties.f!.anyOf).toBeUndefined(); // strict: no file wrap + expect( + new AjvJsonSchemaValidator().getValidator(result)({ cfg: { v: 'd' }, f: 'bytes', alias: 'a', wrapAlias: 'w', name: 'n' }).valid + ).toBe(true); + }); + + test('zod registry refs keep the loosening (stable under every mutation)', () => { + // Registry emissions — `$defs` at the conversion root, refs of the exact + // shape `#/$defs/`, bare `#` — are zod's own output: the guard must + // NOT fire, and the loosen family may mutate `$defs` entries freely IN + // PLACE without breaking the refs. + const reg = z + .object({ + q: z.string(), + du: z.discriminatedUnion('t', [z.object({ t: z.literal('a').catch('a') }), z.object({ t: z.literal('b') })]), + c: z.number().catch(0) + }) + .meta({ id: 'Reg' }); + const schema = z.object({ x: reg, y: reg, name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const properties = result.properties as Record>; + expect(properties.x!.$ref).toBe('#/$defs/Reg'); + const entry = (result.$defs as Record>).Reg!; + // Loosening fired INSIDE the entry — the tolerant field left `required` + // and the DU's oneOf was renamed — while the registry ref still resolves. + expect(entry.required).not.toContain('c'); + expect((entry.properties as Record>).du!.oneOf).toBeUndefined(); + expect( + new AjvJsonSchemaValidator().getValidator(result)({ x: { q: 'a', du: { t: 'a' } }, y: { q: 'b', du: { t: 'b' } }, name: 'n' }) + .valid + ).toBe(true); + + // Recursive schemas emit registry-shaped cycle refs — loosening stays on. + type Tree = { v: string; kids?: Tree[] }; + const tree: z.ZodType = z.lazy(() => z.object({ v: z.string(), kids: z.array(tree).optional() })); + const rec = standardSchemaToJsonSchema(z.object({ tree, c: z.number().catch(0), name: z.string() }), 'output'); + expect(rec.required).toEqual(['tree', 'name']); + expect(new AjvJsonSchemaValidator().getValidator(rec)({ tree: { v: 'r', kids: [{ v: 'k' }] }, name: 'n' }).valid).toBe(true); + + // A POSITIVE-polarity hand-authored registry-shaped alias is loosen-safe + // too: entries mutate in place, so the alias observes the loosened entry. + const aliased = standardSchemaToJsonSchema( + z.object({ x: reg, alias: z.unknown().meta({ $ref: '#/$defs/Reg' }), name: z.string() }), + 'output' + ); + expect(((aliased.$defs as Record>).Reg!.required as string[]) ?? []).not.toContain('c'); + expect( + new AjvJsonSchemaValidator().getValidator(aliased)({ + x: { q: 'a', du: { t: 'a' } }, + alias: { q: 'b', du: { t: 'b' } }, + name: 'n' + }).valid + ).toBe(true); + }); + + test('nested nodes are never stamped by the loosen rewrite (vacuous keywords prove nothing)', () => { + // `properties`/`required` are vacuous for non-object instances — a + // hand-authored type-less oneOf member is satisfiable by 42, so stamping + // an enforced `type: 'object'` would reject pre-PR-valid payloads. The + // root's wrap stamp comes from the strict pre-loosen snapshot instead. + const schema = z.object({ + counted: z.number().default(0), // unrelated loosening trigger + poly: z.unknown().meta({ oneOf: [{ properties: { a: { type: 'string' } } }], anyOf: [{}] }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const poly = (result.properties as Record>).poly!; + expect(poly.type).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ counted: 1, poly: 42, name: 'n' }).valid).toBe(true); + // Explicitly-typed members (zod DU emissions) keep the stamp — the + // 2025-era legacy-wrap protection is unaffected. + const du = standardSchemaToJsonSchema( + 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() }) + ]) + .meta({ anyOf: [{ type: 'object' }, { type: 'null' }] }), + 'output' + ); + expect(du.type).toBe('object'); + }); + + test('polarity-consumed registry refs and unevaluated* keywords disable loosening', () => { + // (1) A registry-shaped ref under `not`: the entry loosens IN PLACE, so + // the negated consumer would observe every loosening as a tightening — + // the guard ships strict instead and `{}` keeps failing the target. + const regY = z.object({ q: z.string().default('d') }).meta({ id: 'Y' }); + const negated = standardSchemaToJsonSchema( + z.object({ cfg: regY, guarded: z.unknown().meta({ not: { $ref: '#/$defs/Y' } }), name: z.string() }), + 'output' + ); + expect(((negated.$defs as Record>).Y!.required as string[]) ?? []).toContain('q'); // strict + expect(new AjvJsonSchemaValidator().getValidator(negated)({ cfg: { q: 'x' }, guarded: {}, name: 'n' }).valid).toBe(true); + + // (2) A registry ref beside `unevaluatedProperties: false`: the catch + // degrade would strip the target's `properties`, so the still-resolving + // ref would stop contributing evaluation annotations. + const regX = z.object({ p: z.string() }).catch({ p: 'd' }).meta({ id: 'X' }); + const annotated = standardSchemaToJsonSchema( + z.object({ cfg: regX, guarded: z.unknown().meta({ $ref: '#/$defs/X', unevaluatedProperties: false }), name: z.string() }), + 'output' + ); + expect(((annotated.$defs as Record>).X!.properties as Record).p).toBeDefined(); + expect(new AjvJsonSchemaValidator().getValidator(annotated)({ cfg: { p: 'v' }, guarded: { p: 'w' }, name: 'n' }).valid).toBe(true); + + // (3) No ref at all: a hand-authored `unevaluatedProperties: false` on a + // zod-emitted union whose member the catch degrade would skeletonize + // loses that member's annotation contributions identically. + const refFree = standardSchemaToJsonSchema( + z.object({ + x: z + .union([z.looseObject({ p: z.string() }).catch({ p: 'd' }), z.looseObject({ q: z.number() })]) + .meta({ unevaluatedProperties: false }), + name: z.string() + }), + 'output' + ); + const member = ((refFree.properties as Record>).x!.anyOf as Array>)[0]!; + expect(member.properties).toBeDefined(); // strict: no skeleton + expect(new AjvJsonSchemaValidator().getValidator(refFree)({ x: { p: 'v' }, name: 'n' }).valid).toBe(true); + }); + + test('the tuple wrap never mutates a registry-owned prefixItems array', () => { + // zod's Object.assign meta merge shares the user's array by reference and + // the override runs before the terminal deep clone — an in-place element + // write would corrupt registry metadata, nest one more anyOf per + // conversion, and leak false null-tolerance into input advertisements. + const userPrefixItems = [{ type: 'string' }, { type: 'number' }]; + const schema = z.object({ + t: z.tuple([z.string().default('x'), z.number()]).meta({ prefixItems: userPrefixItems }), + name: z.string() + }); + const first = standardSchemaToJsonSchema(schema, 'output'); + const second = standardSchemaToJsonSchema(schema, 'output'); + + expect(userPrefixItems).toEqual([{ type: 'string' }, { type: 'number' }]); // registry untouched + // No nesting growth: both conversions advertise the identical single wrap. + expect(second).toEqual(first); + const prefixItems = (first.properties as Record>).t!.prefixItems as Array>; + expect(prefixItems[0]).toEqual({ anyOf: [{ type: 'string' }, { type: 'null' }] }); + }); + + test('legacy-draft reference spellings disable loosening (dependencies, $recursiveRef)', () => { + // Ajv2020 with strict:false still compiles and ENFORCES draft-07 + // `dependencies` and 2019-09 `$recursiveRef` — both must ship strict. + const gated = standardSchemaToJsonSchema( + z.object({ + 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() }) + ]), + gated: z.unknown().meta({ type: 'object', dependencies: { a: { $ref: '#/properties/du/oneOf/0' } } }), + name: z.string() + }), + 'output' + ); + expect((gated.properties as Record>).du!.oneOf).toBeDefined(); // strict: no rename + expect(new AjvJsonSchemaValidator().getValidator(gated)({ du: { t: 'a' }, gated: { b: 1 }, name: 'n' }).valid).toBe(true); + + const recursive = standardSchemaToJsonSchema( + z.object({ + cfg: z.number().default(0), + guarded: z.unknown().meta({ not: { $recursiveRef: '#' } }), + name: z.string().default('n') + }), + 'output' + ); + // Strict: the root keeps required/additionalProperties, so `{}` still + // fails the root and the negation keeps passing (pre-PR behavior). + expect(recursive.required).toEqual(['cfg', 'guarded', 'name']); + expect(new AjvJsonSchemaValidator().getValidator(recursive)({ cfg: 1, guarded: {}, name: 'n' }).valid).toBe(true); + }); + + test('the wrap stamp reads the strict pre-loosen emission (parity with main at roots)', () => { + // Main decided the 2025-era wrap on the RAW emission — the epilogue now + // snapshots the proof on the strict pass, so loosen mutations of + // proof-relevant keys cannot flip the wire shape either direction. + // Keyword-presence-provable root oneOf: stamped with AND without an + // unrelated .default() trigger. + const root = z + .union([z.object({ c: z.number().default(0), a: z.string() }), z.null()]) + .meta({ oneOf: [{ properties: { a: { type: 'string' } } }] }); + const loosenedRoot = standardSchemaToJsonSchema(root, 'output'); + expect(loosenedRoot.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(loosenedRoot)).toBe(false); + const untouchedRoot = standardSchemaToJsonSchema( + z.union([z.object({ a: z.string() }), z.null()]).meta({ oneOf: [{ properties: { a: { type: 'string' } } }] }), + 'output' + ); + expect(untouchedRoot.type).toBe('object'); + + // FAILING root oneOf (no object keyword): the allOf-push deletes the + // oneOf, but the snapshot keeps reading it — typeless and wrapped, with + // and without the trigger. + const failing = z + .union([z.object({ c: z.number().default(0), a: z.string() }), z.object({ b: z.string() })]) + .meta({ oneOf: [{ minimum: 1 }] }); + const failingLoosened = standardSchemaToJsonSchema(failing, 'output'); + expect(failingLoosened.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(failingLoosened)).toBe(true); + const failingUntouched = standardSchemaToJsonSchema( + z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]).meta({ oneOf: [{ minimum: 1 }] }), + 'output' + ); + expect(failingUntouched.type).toBeUndefined(); + + // NESTED member whose provability evidence the push relocates: the + // snapshot still proves it through the member's oneOf. + const nested = z.union([ + z.object({ c: z.number().default(0), a: z.string() }), + z.unknown().meta({ oneOf: [{ properties: { q: { type: 'string' } } }], anyOf: [{}] }) + ]); + expect(standardSchemaToJsonSchema(nested, 'output').type).toBe('object'); + + // Catch degrade deleting meta-authored proof keys (`required`): the + // snapshot read them before the degrade. + const catchRoot = z + .union([z.object({ x: z.number() }), z.null()]) + .catch({ x: 1 }) + .meta({ required: ['x'] }); + expect(standardSchemaToJsonSchema(catchRoot, 'output').type).toBe('object'); + }); + + test('a solo additionalProperties drop still fires the oneOf rename', () => { + // The drop is member-visible loosening through positive-polarity registry + // refs: both $defs entries lose additionalProperties: false, the payload + // matches both, and a surviving exactly-one oneOf would reject everything. + const A = z.object({ kind: z.string() }).meta({ id: 'ReproA' }); + const B = z.object({ kind: z.string(), extra: z.number() }).meta({ id: 'ReproB' }); + const schema = z.object({ + x: A, + y: B, + // A string-keyed record carrier: not missing-key-tolerant, so no other + // loosen mutation fires — the drop must set the flag itself. + choice: z.record(z.string(), z.unknown()).meta({ oneOf: [{ $ref: '#/$defs/ReproA' }, { $ref: '#/$defs/ReproB' }] }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + const choice = (result.properties as Record>).choice!; + expect(choice.oneOf).toBeUndefined(); + expect(Array.isArray(choice.anyOf)).toBe(true); + const payload = { x: { kind: 'k' }, y: { kind: 'k', extra: 1 }, choice: { kind: 'k', extra: 1 }, name: 'n' }; + expect(new AjvJsonSchemaValidator().getValidator(result)(payload).valid).toBe(true); + }); + + test('refs inside draft-07 additionalItems disable loosening (cfworker enforces it)', () => { + // @cfworker/json-schema validates additionalItems in every draft mode and + // collects refs inside it — a dangling one throws at validator build. + const schema = z.object({ + 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() }) + ]), + arr: z.unknown().meta({ type: 'array', items: [{ type: 'string' }], additionalItems: { $ref: '#/properties/du/oneOf/0' } }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).du!.oneOf).toBeDefined(); // strict shipped + const validate = new CfWorkerJsonSchemaValidator().getValidator(result); + expect(validate({ du: { t: 'a' }, arr: ['head', { t: 'a', x: 'v' }], name: 'n' }).valid).toBe(true); + expect(validate({ du: { t: 'a' }, arr: ['head', { z: 1 }], name: 'n' }).valid).toBe(false); // ref still enforces + + // Catch-degrade spelling: the ref's target would be deleted. + const degraded = standardSchemaToJsonSchema( + z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }), + arr: z + .unknown() + .meta({ type: 'array', items: [{ type: 'string' }], additionalItems: { $ref: '#/properties/cfg/properties/q' } }), + name: z.string() + }), + 'output' + ); + const cfg = (degraded.properties as Record>).cfg!; + expect((cfg.properties as Record).q).toEqual({ type: 'string' }); // strict: no degrade + expect(new CfWorkerJsonSchemaValidator().getValidator(degraded)({ cfg: { q: 'x' }, arr: ['head', 'tail'], name: 'n' }).valid).toBe( + true + ); + }); + + test('relocated members keep their stamp beside a null-membered user anyOf', () => { + // The loosen rewrite relocates the DU members under allOf beside the + // user's .meta({anyOf}) — the epilogue's strict pre-loosen snapshot read + // the emitted oneOf (first-present-key-wins) and stamps the root, where + // the post-loosen document alone could not prove it (the user's anyOf has + // a null member). + 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() }) + ]) + .meta({ anyOf: [{ type: 'object' }, { type: 'null' }] }); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + }); + + test('untouched multi-key composition roots keep the typeless root (first-present-key-wins)', () => { + // No loosening fires here, so the emission reaches the epilogue proof + // untouched: its first present composition key (the union's anyOf) has a + // null member, so main never stamped — an any-key-may-prove rule reading + // the user's allOf instead would flip the 2025-era legacy wrap for a + // WORKING pre-#2464 registration. + const schema = z.union([z.object({ a: z.string() }), z.null()]).meta({ allOf: [{ type: 'object' }] }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect(result.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(result)).toBe(true); + }); + + test('bigint-valued literal output roots throw across spellings', () => { + // These emit an explicit {type: 'number', const: …} under + // unrepresentable: 'any' (bypassing the typeless guard), yet no result can + // ever be serialized — JSON.stringify throws on BigInt. Pre-#2464 they threw. + expect(() => standardSchemaToJsonSchema(z.literal(1n), 'output')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.literal(1n).optional(), 'output')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.literal(1n).readonly(), 'output')).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema( + z.lazy(() => z.literal(1n)), + 'output' + ) + ).toThrow(/must describe objects/); + // The input spelling keeps throwing via the explicit-type guard. + expect(() => standardSchemaToJsonSchema(z.literal(1n), 'input')).toThrow(/got type/); + }); + + test('untouched intersection output roots keep their pre-#2464 verdicts', () => { + // A loud conjunct must throw even though the object conjunct could prove the + // root (an intersection value can never satisfy both sides) ... + expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.bigint()), 'output')).toThrow( + /must describe objects/ + ); + // ... and a quiet non-provable conjunct keeps the typeless root, preserving + // the 2025-era legacy wrap for a WORKING pre-#2464 registration. + const anyConjunct = standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.any()), 'output'); + expect(anyConjunct.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(anyConjunct)).toBe(true); + }); + + test('an all-date union side keeps date intersections loud on output', () => { + // `z.union([z.date(), z.date()])` admits only Dates — no value satisfies + // both it and the object side, and pre-#2464 the conversion threw on the + // date. The union spelling must not slip past the direct-side parity check. + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.date()])), 'output') + ).toThrow(/must describe objects/); + // A date union ROOT stays quiet — the date override makes those emissions + // wire-truthful, so 'timestamp or one of these' style tools keep listing. + const root = standardSchemaToJsonSchema(z.union([z.date(), z.date()]), 'output'); + expect(root.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(root)).toBe(true); + }); + + test('mixed quiet-member unions carrying a date keep intersections loud on output', () => { + // A quiet co-member that adds no JSON-satisfiable value (never matches + // nothing; Files and non-finite literals are never JSON objects) must not + // launder the union's date-sidedness: all three spellings threw on the + // date pre-#2464. + for (const coMember of [z.never(), z.literal(Infinity), z.file()]) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), coMember])), 'output') + ).toThrow(/must describe objects/); + } + // No date member, no date side: a union of nevers listed pre-#2464 (it + // emitted {not: {}} members without throwing) and must keep doing so. + const noDate = standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.never(), z.never()])), 'output'); + expect(noDate.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(noDate)).toBe(true); + }); + + test('date-carrying non-object unions keep intersections loud on output (structural)', () => { + // Every co-member spelling that provably cannot satisfy the object side — + // representable non-objects (short-circuited the old may-be-object bail) + // and quiet compositions of unsatisfiable kinds (escaped the old leaf-kind + // enumeration) — must not launder the date: all seven threw pre-#2464. + const coMembers = [ + z.string(), + z.number(), + z.null(), + z.literal('x'), + z.union([z.never(), z.never()]), + z.intersection(z.never(), z.never()), + z.union([z.file(), z.literal(Infinity)]), + // A JSON array is never a JSON object — array/tuple members are as + // provably non-object as the primitives above. + z.array(z.string()), + z.tuple([z.string()]) + ]; + for (const coMember of coMembers) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.date(), coMember])), 'output') + ).toThrow(/must describe objects/); + } + // Loud non-date co-member verdicts survive the bail too, on BOTH io paths + // (the input spelling used to list as a stamped phantom tool). + for (const io of ['output', 'input'] as const) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.union([z.bigint(), z.array(z.string())])), io) + ).toThrow(/must describe objects/); + } + // A PLAIN array side (no union, no date) was no-throw pre-#2464 — the + // defined-but-quiet verdict keeps it listing. + const plainArray = standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.array(z.string())), 'output'); + expect(plainArray.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(plainArray)).toBe(true); + // A may-be-object member makes the intersection satisfiable — keeps listing. + const satisfiable = standardSchemaToJsonSchema( + z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), + 'output' + ); + expect(satisfiable.type).toBeUndefined(); + expect(isNonObjectJsonSchemaRoot(satisfiable)).toBe(true); + // Root/union positions are untouched: outside intersections a + // representable member discharges the union (a working + // 'timestamp-or-string' tool on either io path). + expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'output').type).toBeUndefined(); + expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); + }); + + test('a may-be-object union member cannot launder loudness past a non-object conjunct', () => { + // The union's object member makes the union satisfiable against an OBJECT + // conjunct — but against a provably non-object conjunct (array/string) the + // object possibility is dead, and the loud date/bigint member must + // surface: all of these threw pre-#2464. + expect(() => + standardSchemaToJsonSchema(z.intersection(z.array(z.string()), z.union([z.date(), z.object({ b: z.number() })])), 'output') + ).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema(z.intersection(z.string(), z.union([z.date(), z.object({ b: z.number() })])), 'output') + ).toThrow(/must describe objects/); + for (const io of ['output', 'input'] as const) { + expect(() => + standardSchemaToJsonSchema(z.intersection(z.array(z.string()), z.union([z.bigint(), z.object({ b: z.number() })])), io) + ).toThrow(/must describe objects/); + } + // Against an OBJECT conjunct the same union keeps listing (satisfiable) — + // the pinned control — and its inner loudness rides through NESTED + // intersections until a non-object conjunct kills the object possibility. + const satisfiable = standardSchemaToJsonSchema( + z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), + 'output' + ); + expect(satisfiable.type).toBeUndefined(); + expect(() => + standardSchemaToJsonSchema( + z.intersection( + z.intersection(z.object({ a: z.string() }), z.union([z.date(), z.object({ b: z.number() })])), + z.array(z.string()) + ), + 'output' + ) + ).toThrow(/must describe objects/); + }); + + test('piped and undefined-filtered loud literal output roots throw', () => { + // A bigint literal on a pipe's OUT side emits {type: 'number', const: 1}, + // bypassing the typeless guard — pre-#2464 the conversion threw. + expect(() => + standardSchemaToJsonSchema( + z + .number() + .transform(() => 1n) + .pipe(z.literal(1n)), + 'output' + ) + ).toThrow(/must describe objects/); + expect(() => + standardSchemaToJsonSchema( + z + .number() + .transform(() => 1n) + .pipe(z.literal([1n, 2n])), + 'output' + ) + ).toThrow(/must describe objects/); + // zod filters undefined out of the value list, emitting {type: 'string', + // const: 'a'} — but the undefined value threw pre-#2464. + expect(() => standardSchemaToJsonSchema(z.literal([undefined, 'a']), 'output')).toThrow(/must describe objects/); + // Representable literal output roots keep listing. + expect(standardSchemaToJsonSchema(z.literal('a'), 'output').type).toBe('string'); + }); + + test('the catch degrade keeps reference targets ($anchor) compilable', () => { + // $anchor/$dynamicAnchor/$id constrain no instance value — deleting them only + // dangles inbound $refs, making the advertisement uncompilable (every + // callTool would fail at Ajv compile, before the request is sent). + const schema = z.object({ + cfg: z.object({ q: z.string() }).catch({ q: 'd' }).meta({ $anchor: 'cfgAnchor' }), + alias: z.unknown().meta({ $ref: '#cfgAnchor' }), + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + expect((result.properties as Record>).cfg!.$anchor).toBe('cfgAnchor'); + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate({ cfg: { q: 'x' }, alias: { q: 'y' }, name: 'n' }).valid).toBe(true); + }); + + test('skeletons and the catch degrade keep member anchors and $defs compilable', () => { + // (1) compositionTypeSkeleton must carry member reference targets — a + // catch-of-union member's $anchor with a sibling $ref alias dangled. + const unionSchema = z.object({ + cfg: z.union([z.object({ q: z.string() }).meta({ $anchor: 'cfgA' }), z.object({ r: z.number() })]).catch({ q: 'd' }), + alias: z.unknown().meta({ $ref: '#cfgA' }), + name: z.string() + }); + const unionResult = standardSchemaToJsonSchema(unionSchema, 'output'); + const unionValidate = new AjvJsonSchemaValidator().getValidator(unionResult); + expect(unionValidate({ cfg: { q: 'x' }, alias: { q: 'y' }, name: 'n' }).valid).toBe(true); + + // (2) $defs is a pure reference CONTAINER — deleting it from a catch node + // dangled cross-subtree $refs into it. + const defsSchema = z.object({ + cfg: z + .object({ q: z.string() }) + .catch({ q: 'd' }) + .meta({ $defs: { X: { type: 'string' } } }), + alias: z.unknown().meta({ $ref: '#/properties/cfg/$defs/X' }), + name: z.string() + }); + const defsResult = standardSchemaToJsonSchema(defsSchema, 'output'); + const defsValidate = new AjvJsonSchemaValidator().getValidator(defsResult); + expect(defsValidate({ cfg: { q: 'x' }, alias: 's', name: 'n' }).valid).toBe(true); + }); + + test('date-containing intersections stay loud on the output path', () => { + // No value satisfies both an object side and a Date, and every + // date-containing intersection threw pre-#2464 — the quiet output-path date + // verdict must not flow into intersection positions. + expect(() => standardSchemaToJsonSchema(z.intersection(z.object({ a: z.string() }), z.date()), 'output')).toThrow( + /must describe objects/ + ); + // Wire-truthful date output shapes keep listing (pinned elsewhere too). + expect(Array.isArray(standardSchemaToJsonSchema(z.date().nullable(), 'output').anyOf)).toBe(true); + }); + + test('the allOf-push stamps the sound type when user conjuncts defeat the proof', () => { + // A .meta() carrying BOTH a non-all-object anyOf AND a non-object-provable + // allOf conjunct defeats every() on every composition key after the push — + // the pushed members still prove the value is an object, so the rewrite + // stamps the explicit type itself (pre-#2464 these roots were stamped via + // their emitted 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() }) + ]) + .meta({ anyOf: [{ type: 'object' }, { type: 'null' }], allOf: [{ minProperties: 1 }] }); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(result.type).toBe('object'); + expect(isNonObjectJsonSchemaRoot(result)).toBe(false); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + + test('nullable date output roots list and validate their wire forms', () => { + // The date override makes date-rooted OUTPUT emissions wire-truthful (a raw + // Date ships as an ISO string), so 'timestamp or null' must list — classifying + // date as loud on output killed the whole tools/list for it. + const result = standardSchemaToJsonSchema(z.date().nullable(), 'output'); + + expect(Array.isArray(result.anyOf)).toBe(true); + const validate = new AjvJsonSchemaValidator().getValidator(result); + expect(validate(new Date().toISOString()).valid).toBe(true); + expect(validate(null).valid).toBe(true); + // Input-path date members stay loud (pinned elsewhere: union(date,date) throws) + // and output unions still turn loud with a genuinely-loud co-member. + expect(() => standardSchemaToJsonSchema(z.union([z.date(), z.bigint()]), 'output')).toThrow(/must describe objects/); + }); + + test('custom .meta() keys are annotation-opaque at both carve-out sites', () => { + // zod merges arbitrary .meta() keys verbatim and 2020-12 validators ignore + // unknown keywords — they are annotations by construction, like x-*. + const schema = z.object({ + v: z.number().meta({ ui: { oneOf: [1, 2] } }), + c: z + .number() + .catch(0) + .meta({ ui: { hint: 'slider' }, title: 't' }), + counted: z.number().default(0), // trips the loosened flag + name: z.string() + }); + const result = standardSchemaToJsonSchema(schema, 'output'); + const properties = result.properties as Record>; + + // The oneOf rewrite must not recurse into the custom key's DATA ... + expect(properties.v!.ui).toEqual({ oneOf: [1, 2] }); + // ... and the catch degrade must keep it (while still stripping constraints). + expect(properties.c).toEqual({ default: 0, title: 't', ui: { hint: 'slider' } }); + }); + + test('z.file() output fields also accept their wire form', () => { + // A File serializes to {} on the wire while zod emits string/binary. + const result = standardSchemaToJsonSchema(z.object({ f: z.file(), name: z.string() }), 'output'); + + expect(new AjvJsonSchemaValidator().getValidator(result)({ f: {}, name: 'n' }).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(); + }); +});