From 3c067c4d3ef67caaac48bbf9ccf316986e2149c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:22:37 +0000 Subject: [PATCH 01/16] fix(core-internal): make zod toJSONSchema conversion wire-truthful for tool schemas The SDK validates tool payloads with the user's zod schema but ships the raw object, so the advertised JSON Schema must describe that raw shape. Pass zod-scoped conversion options (via libraryOptions on the Standard JSON Schema path, and directly on the zod 4.0-4.1 fallback): - unrepresentable: 'any' so one z.date()/z.bigint() field no longer throws and fails the entire tools/list response - rewrite z.date() to {type: 'string', format: 'date-time'}, the shape JSON.stringify actually produces for a Date - for output schemas, drop .default()-carrying fields from required and drop additionalProperties: false on plain z.object() (kept for z.strictObject()), so validating clients accept legitimate structuredContent the server ships as returned Fixes #2464 Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 14 +++ .../core-internal/src/util/standardSchema.ts | 63 +++++++++- .../test/util/standardSchema.test.ts | 44 +++++++ .../util/standardSchema.zodFallback.test.ts | 12 ++ .../test/server/toolSchemaWireShape.test.ts | 115 ++++++++++++++++++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 .changeset/zod-tojsonschema-wire-truthful.md create mode 100644 packages/server/test/server/toolSchemaWireShape.test.ts diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md new file mode 100644 index 0000000000..8827ec783c --- /dev/null +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -0,0 +1,14 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or any +unrepresentable type) 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. Output schemas no longer advertise constraints the +server doesn't enforce on the raw `structuredContent` it ships: `.default()`-carrying fields +are dropped from `required`, and `additionalProperties: false` is dropped for plain +`z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate +tool results. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d904c7f5fa..eff654bf9b 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -169,6 +169,57 @@ 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`. + * - `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 drop `.default()`-carrying properties from `required`: zod fills + * defaults during validation, but the shipped payload may legitimately omit them. + */ +function zodConversionOptions(io: 'input' | 'output'): Pick { + return { + unrepresentable: 'any', + override: ctx => { + const def = ctx.zodSchema._zod.def; + if (def.type === 'date') { + for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; + ctx.jsonSchema.type = 'string'; + ctx.jsonSchema.format = 'date-time'; + return; + } + if (io !== 'output' || def.type !== 'object') return; + const isStrict = def.catchall?._zod.def.type === 'never'; + if (!isStrict && ctx.jsonSchema.additionalProperties === false) { + delete ctx.jsonSchema.additionalProperties; + } + const properties = ctx.jsonSchema.properties; + const required = ctx.jsonSchema.required; + if (properties && Array.isArray(required)) { + const filtered = required.filter(name => { + const property = properties[name]; + return typeof property !== 'object' || property.default === undefined; + }); + if (filtered.length !== required.length) { + if (filtered.length === 0) delete ctx.jsonSchema.required; + else ctx.jsonSchema.required = filtered; + } + } + } + }; +} + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * @@ -184,7 +235,11 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in const std = schema['~standard']; let result: Record; if (std.jsonSchema) { - result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET }); + result = std.jsonSchema[io]({ + target: JSON_SCHEMA_CONVERSION_TARGET, + // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. + libraryOptions: std.vendor === 'zod' ? zodConversionOptions(io) : undefined + }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. @@ -203,7 +258,11 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' ); } - result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io }) as Record; + result = z.toJSONSchema(schema as unknown as z.ZodType, { + target: JSON_SCHEMA_CONVERSION_TARGET, + io, + ...zodConversionOptions(io) + }) as Record; } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 8856592ff0..e00746339a 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -40,3 +40,47 @@ 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('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('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(); + }); +}); From 034ccc128ca44c438c23cfbe67c63a7c9e86b4e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:51:42 +0000 Subject: [PATCH 02/16] fix(core-internal): address review findings on zod conversion options - Keep user annotations (.describe()/.meta()) on rewritten z.date() fields: under unrepresentable: 'any' the node carries only annotation keywords, so the previous wipe loop deleted exactly the user's metadata and nothing else. - Key the output required-filter on the zod shape instead of the emitted default keyword: a registered .default() hides its default behind a $ref (and stayed required), a .meta({default}) annotation on a genuinely-required field was wrongly dropped, and undefined- accepting fields (z.any(), z.unknown(), z.undefined()) stayed required even though JSON.stringify drops undefined-valued keys from the wire. A field is now dropped from required iff validating undefined succeeds (missing key semantics). - Opt the elicitation path out of the graceful-degradation options via a new unrepresentable: 'throw' conversion option: a z.date() rewritten to string/date-time would pass the wire checks, but the accepted response could never satisfy z.date() on handler re-entry, so acceptedContent() would silently discard the user's answer. z.date() keeps throwing the documented TypeError before anything is sent. Co-Authored-By: Claude --- .../core-internal/src/shared/elicitation.ts | 7 ++- .../core-internal/src/util/standardSchema.ts | 56 +++++++++++++++---- .../test/shared/inputRequired.test.ts | 12 ++++ .../test/util/standardSchema.test.ts | 44 +++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) 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 eff654bf9b..f773a470ca 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -194,7 +194,8 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { const def = ctx.zodSchema._zod.def; if (def.type === 'date') { - for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key]; + // 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; @@ -204,13 +205,13 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { - const property = properties[name]; - return typeof property !== 'object' || property.default === undefined; - }); + 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) { if (filtered.length === 0) delete ctx.jsonSchema.required; else ctx.jsonSchema.required = filtered; @@ -220,6 +221,19 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { +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'; +} + +export function standardSchemaToJsonSchema( + schema: StandardJSONSchemaV1, + io: 'input' | 'output' = 'input', + options?: StandardSchemaToJsonSchemaOptions +): Record { const std = schema['~standard']; + const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io); let result: Record; if (std.jsonSchema) { result = std.jsonSchema[io]({ target: JSON_SCHEMA_CONVERSION_TARGET, // Non-zod vendors receive no libraryOptions, so their behavior is unchanged. - libraryOptions: std.vendor === 'zod' ? zodConversionOptions(io) : undefined + libraryOptions: std.vendor === 'zod' ? zodOptions : undefined }); } else if (std.vendor === 'zod') { // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). @@ -261,7 +297,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in result = z.toJSONSchema(schema as unknown as z.ZodType, { target: JSON_SCHEMA_CONVERSION_TARGET, io, - ...zodConversionOptions(io) + ...zodOptions }) as Record; } else { throw new Error( 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 e00746339a..7fbfc28bc9 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -60,6 +60,22 @@ describe('zod conversion options (#2464)', () => { 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'); @@ -69,6 +85,34 @@ describe('zod conversion options (#2464)', () => { expect(result.required).toEqual(['name']); }); + test('a registered .default() (emitted as $ref) is still dropped from output required', () => { + const schema = z.object({ counted: z.number().default(0).meta({ id: 'StandardSchemaTestCounted' }), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The `default` keyword hides inside $defs behind a bare $ref; the filter must + // key on the zod shape, not the emitted JSON. + expect((result.properties as Record>).counted?.$ref).toBeDefined(); + expect(result.required).toEqual(['name']); + }); + + test('a required field annotated with .meta({default}) stays required in output schemas', () => { + const schema = z.object({ label: z.string().meta({ default: 'n/a' }), other: z.number() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The annotation carries a `default` keyword, but validation still requires the + // field, so every shipped payload carries it. + expect(result.required).toEqual(['label', 'other']); + }); + + test('undefined-accepting fields are not advertised as required in output schemas', () => { + const schema = z.object({ a: z.any(), u: z.unknown(), v: z.undefined(), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // A raw payload with an undefined-valued key passes validation, and + // JSON.stringify drops the key from the wire entirely. + expect(result.required).toEqual(['name']); + }); + test('plain z.object() output schemas do not advertise additionalProperties:false', () => { const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output'); From d11d6fc46204f87864ab02dfa68ab8f20dfd06e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 16:40:16 +0000 Subject: [PATCH 03/16] fix(core-internal): harden the missing-key probe and cover enum-keyed records - fieldAcceptsMissingKey no longer lets a throwing .transform()/.refine() escape the probe: depending on the zod build, ~standard.validate(undefined) either throws synchronously or returns a rejecting Promise whose unhandled rejection crashes the process during tools/list conversion. Both modes now conservatively keep the field required, and the Promise branch attaches a no-op catch so no rejection floats. - Enum-keyed records (def.type === 'record') also emit a required list zod does not enforce when the shared value schema is defaulted or undefined-accepting; the override now drops it (all-or-nothing, since every key shares the one value schema). - Move StandardSchemaToJsonSchemaOptions above the conversion function's JSDoc block so the doc comment re-attaches to the function it describes, and give the interface its own one-liner. - Stop overclaiming in the changeset and JSDoc: BigInt values embedded as defaults or metadata (.default(0n), .meta({default: 1n})) still fail conversion inside zod's own processors, outside the override's reach. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 22 ++++--- .../core-internal/src/util/standardSchema.ts | 59 +++++++++++++------ .../test/util/standardSchema.test.ts | 31 ++++++++++ 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 8827ec783c..9eeabaf16f 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -3,12 +3,16 @@ '@modelcontextprotocol/server': patch --- -Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or any -unrepresentable type) 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. Output schemas no longer advertise constraints the -server doesn't enforce on the raw `structuredContent` it ships: `.default()`-carrying fields -are dropped from `required`, and `additionalProperties: false` is dropped for plain -`z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate -tool results. +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.) +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. +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/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index f773a470ca..c5058befae 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -180,13 +180,16 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * * - `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 drop `.default()`-carrying properties from `required`: zod fills - * defaults during validation, but the shipped payload may legitimately omit 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. */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { @@ -200,7 +203,16 @@ function zodConversionOptions(io: 'input' | 'output'): Pick {}); + return false; + } + return result.issues === undefined; + } catch { + return false; + } } -/** - * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. - * - * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt - * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). - * Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`, - * so for `io: 'input'` this function defaults `type` to `"object"` when absent - * and throws on an explicit non-object `type` (e.g. `z.string()`). For - * `io: 'output'` a non-object root is returned as-is; the `"object"` default is - * applied only when the root is provably object-shaped. - */ +/** Options for {@linkcode standardSchemaToJsonSchema}. */ export interface StandardSchemaToJsonSchemaOptions { /** * How types JSON Schema cannot represent (`z.date()`, `z.bigint()`, …) are handled @@ -262,6 +274,17 @@ export interface StandardSchemaToJsonSchemaOptions { unrepresentable?: 'wire' | 'throw'; } +/** + * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. + * + * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt + * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). + * Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`, + * so for `io: 'input'` this function defaults `type` to `"object"` when absent + * and throws on an explicit non-object `type` (e.g. `z.string()`). For + * `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', diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 7fbfc28bc9..acdaae0676 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -113,6 +113,37 @@ describe('zod conversion options (#2464)', () => { 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 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), name: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'output'); + + // The missing-key probe cannot demonstrate tolerance (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(['n', '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'); From 665a05623fdfff69d303adddc3bb8bc9b5bfca17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:26:24 +0000 Subject: [PATCH 04/16] docs(core-internal): scope wire-truthfulness claims to exclude pipe/coerce outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output schemas containing .transform()/.pipe()/z.coerce still advertise the post-transform shape (io: 'output') while the server validates and ships the raw pre-transform value — a pre-existing mismatch this PR does not address. Note it as a known residual gap in the zodConversionOptions contract and scope the changeset claim accordingly, instead of rewriting pipe nodes in the override: a per-node input-side re-conversion would break $refs to registered schemas (a nested conversion's $defs land at the wrong document root), and advertising output schemas with input semantics wholesale is a design decision that interacts with SEP-2106 non-object output roots. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 9 ++++++--- packages/core-internal/src/util/standardSchema.ts | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 9eeabaf16f..8afe3a4ab0 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -13,6 +13,9 @@ Output schemas no longer advertise constraints the server doesn't enforce on the `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. -Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted -form grammar cannot round-trip, including `z.date()`. +`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.) 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/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index c5058befae..110e4e16ae 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,6 +190,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - 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. + * + * Known residual gap: 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). */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { From 802ac991df4d86d81872bc6467077eabaa0cbc00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 18:16:30 +0000 Subject: [PATCH 05/16] docs(core-internal): note zod <4.3.0 override skip on reused cloned schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On zod 4.0-4.2.x, toJSONSchema guards the override hook with 'if (!seen.isParent)' (v4/core/to-json-schema.js), and a schema instance is marked isParent whenever a clone of it (.describe()/.meta()) appears in the same conversion — so a schema reused both bare and via a clone skips sanitization on the bare node: untruthful required/ additionalProperties survive and z.date() emits {}. zod 4.3.0 removed the guard, so the lockfile resolution (4.3.6) and the test suite cannot observe it. Verified against the published 4.2.1 and 4.3.0 tarballs. Document it as a known residual gap and scope the changeset claim; bumping the declared zod floor (^4.2.0 -> ^4.3.0) would close it for the primary path but is a maintainer decision. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 5 ++++- .../core-internal/src/util/standardSchema.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 8afe3a4ab0..87073431e1 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -16,6 +16,9 @@ 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.) Elicitation is unaffected: +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.) 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/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 110e4e16ae..ccef0f896a 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -191,12 +191,18 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * absent from the shipped payload (`.default()`, undefined-accepting types) from * `required`: zod fills defaults during validation, but ships the raw object. * - * Known residual gap: 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). + * Known residual gaps: + * - 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. */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { From d28811df6686c01fd385302fcb6f2577bcd52935 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:10:30 +0000 Subject: [PATCH 06/16] fix(core-internal): close async-default, .catch(), and unrepresentable-root gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fieldAcceptsMissingKey short-circuits on def.type 'default'/'prefault' before the validate(undefined) probe: a defaulted field accepts a missing key by construction, but an async stage (.refine(async ...)) pushed the probe to a Promise and conservatively kept the field in the advertised required list while async-capable server validation accepts the omission. - Output .catch() nodes degrade to an unconstrained schema (annotations and the emitted default kept): catch-validation accepts any raw value — the fallback replaces it only in the parsed result, which the server never ships — so advertising the inner constraints made validating clients reject legitimate results. - The input-root guard keys on the emitted type, which unrepresentable: 'any' erases for bare z.bigint()/z.map()/z.set()/ z.symbol() roots — they were stamped {type: 'object'} and advertised as permanently-uncallable tools. Recover the signal from the zod def so misregistered roots keep throwing the actionable 'must describe objects' error. - Document the InMemoryTransport transport-dependence of the z.date() string/date-time advertisement (pass-by-reference, no JSON round-trip) as a known residual gap in the JSDoc and changeset. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 6 +- .../core-internal/src/util/standardSchema.ts | 57 +++++++++++++++++++ .../test/util/standardSchema.test.ts | 41 +++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 87073431e1..0bb7ee075a 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -18,7 +18,9 @@ these schema shapes. (Output schemas containing `.transform()`/`.pipe()`/`z.coer 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.) Elicitation is -unaffected: +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.) +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/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index ccef0f896a..98cfaad29a 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,6 +190,9 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - 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 degrade to an unconstrained schema (annotations and the + * emitted `default` kept): catch-validation accepts any raw value — the fallback + * replaces it only in the parsed result, which the server never ships. * * Known residual gaps: * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the @@ -203,6 +206,10 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * `if (!seen.isParent)` guard in `v4/core/to-json-schema.js`, removed in zod * 4.3.0), so a schema reused both bare and via a clone leaves the bare node * unsanitized. Full per-node sanitization requires zod >=4.3.0. + * - The `z.date()` → `string`/`date-time` advertisement assumes a serializing + * transport. `InMemoryTransport` passes messages by reference with no JSON + * round-trip, so the raw `Date` the server must ship reaches a validating client + * as a `Date` instance and fails the advertised schema there. */ function zodConversionOptions(io: 'input' | 'output'): Pick { return { @@ -217,6 +224,15 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set([ + '$comment', + 'default', + 'deprecated', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly' +]); + /** * Whether a raw payload that omits this field still passes validation (zod treats a * missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`, @@ -256,6 +288,12 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set(['bigint', 'symbol', 'map', 'set']); + /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index acdaae0676..74256d4a03 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -132,6 +132,47 @@ describe('zod conversion options (#2464)', () => { 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('.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), + 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. + const properties = result.properties as Record>; + expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' } }); + expect(properties.scalar).toEqual({ default: 0 }); + // A raw payload omitting the catch fields also validates, so they are not required. + expect(result.required).toEqual(['name']); + }); + + 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/); + }); + 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), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); From b2bd59da32a993e64ff61901290909c93dd6b672 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:49:58 +0000 Subject: [PATCH 07/16] fix(core-internal): root-safe .catch() degrade, x-* annotations, fuller root guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip the .catch() degrade at the conversion root: deleting 'type: object' there flipped the 2025-era codec's legacy-wrap predicate (isNonObjectJsonSchemaRoot), silently shipping structuredContent as {result: ...} to 2025-era peers for root-level .catch() output schemas that were object-rooted pre-PR. - Preserve x-* vendor-extension keys through the nested .catch() degrade, mirroring the elicitation walker's annotation-only convention — they carry no validation constraint. - Extend NON_OBJECT_UNREPRESENTABLE_ROOTS with 'void', 'undefined', 'nan', and 'function': all degrade to a typeless {} under unrepresentable: 'any' and can never accept a JSON object, so they must keep throwing the actionable root error (z.custom() stays excluded — it can legitimately accept objects). - Document the input-side z.date() round-trip impossibility (advertised string/date-time vs raw-zod input validation) as a known residual gap in the JSDoc and changeset, pointing at z.iso.date()/ z.iso.datetime() as the supported input spellings. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 6 ++-- .../core-internal/src/util/standardSchema.ts | 33 +++++++++++++++---- .../test/util/standardSchema.test.ts | 17 ++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 0bb7ee075a..1347527a10 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -20,7 +20,9 @@ pre-existing gap this change does not address. And on zod 4.0–4.2.x, `toJSONSc 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.) -Elicitation is unaffected: +raw `Date` by reference, so a validating client rejects it over that testing transport. +On the input side, a `z.date()` tool/prompt argument is advertised as `string`/`date-time` +but input validation still runs the raw zod schema, which rejects strings — use +`z.iso.date()`/`z.iso.datetime()` for date-valued inputs.) 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/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 98cfaad29a..e03741ca83 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,11 +190,17 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - 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 degrade to an unconstrained schema (annotations and the - * emitted `default` kept): catch-validation accepts any raw value — the fallback - * replaces it only in the parsed result, which the server never ships. + * - Output `.catch()` nodes (below the root) degrade to an unconstrained schema + * (annotations and the emitted `default` kept): catch-validation accepts any raw + * value — the fallback replaces it only in the parsed result, which the server + * never ships. The conversion root keeps its emitted shape so the 2025-era + * legacy-wrap decision is unchanged. * * Known residual gaps: + * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` + * advertise `string`/`date-time`, but input validation still runs the raw zod + * schema, which rejects strings — such a tool is listed yet uncallable via JSON. + * Use `z.iso.date()`/`z.iso.datetime()` for date-valued inputs. * - 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 @@ -227,9 +233,15 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set(['bigint', 'symbol', 'map', 'set']); +const NON_OBJECT_UNREPRESENTABLE_ROOTS: ReadonlySet = new Set([ + 'bigint', + 'symbol', + 'map', + 'set', + 'void', + 'undefined', + 'nan', + 'function' +]); /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 74256d4a03..a10fb472e2 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1,6 +1,7 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { test('emits type:object for plain z.object schemas', () => { @@ -151,6 +152,7 @@ describe('zod conversion options (#2464)', () => { 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'); @@ -160,10 +162,21 @@ describe('zod conversion options (#2464)', () => { const properties = result.properties as Record>; expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' } }); 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 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('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. @@ -171,6 +184,10 @@ describe('zod conversion options (#2464)', () => { 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/); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From a1484e86ce22f55303a084da2a8b912f9f3c6e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:34:05 +0000 Subject: [PATCH 08/16] fix(core-internal): protect root-composition catch members and unwrap the root guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend the .catch() degrade bail from the conversion root to every position that feeds the output epilogue's root object proof (members of root-level, possibly nested, anyOf/oneOf/allOf compositions): degrading such a member broke isProvablyObjectShapedRoot's every-member proof, left the root typeless, and flipped the 2025-era legacy wrap — silently shipping structuredContent as {result: ...} for previously-working union/intersection output schemas. - The input-root guard now unwraps the zod def chain (optional/ nullable/readonly/default/prefault/catch via innerType, lazy via its getter with a seen-set cycle guard) before consulting the non-object set, so z.bigint().optional() and z.lazy(() => z.bigint()) no longer become phantom {type: 'object'} tools; 'literal' joins the set (typeless literal roots — unrepresentable values like z.literal(undefined) or mixed-type value lists — cannot describe objects; representable single-type literal roots already throw via the explicit-type guard). - Document dynamic catch values (.catch(ctx => ...)) as a known residual gap: zod's catchProcessor throws before the override hook runs, so the degrade covers static fallback values only; changeset claim scoped to match. Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 4 +- .../core-internal/src/util/standardSchema.ts | 81 ++++++++++++++++--- .../test/util/standardSchema.test.ts | 27 +++++++ 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index 1347527a10..e47269316a 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -8,7 +8,9 @@ unrepresentable type such as `z.bigint()`) in a registered tool's schema no long 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.) +defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them — +and so do dynamic catch values, `.catch(ctx => …)`; the `.catch()` degrade covers static +fallback values only.) Output schemas no longer advertise constraints the server doesn't enforce on the raw `structuredContent` it ships: fields that may be legitimately absent (`.default()`, undefined-accepting types) are dropped from `required` — on objects and enum-keyed records — diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index e03741ca83..167a8c6ce5 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -201,6 +201,10 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * advertise `string`/`date-time`, but input validation still runs the raw zod * schema, which rejects strings — such a tool is listed yet uncallable via JSON. * Use `z.iso.date()`/`z.iso.datetime()` for date-valued inputs. + * - 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 @@ -233,11 +237,13 @@ function zodConversionOptions(io: 'input' | 'output'): Pick): boolean { + for (let i = 0; i < path.length; i += 2) { + const key = path[i]; + if ((key !== 'anyOf' && key !== 'oneOf' && key !== 'allOf') || typeof path[i + 1] !== 'number') return false; + } + return true; +} + /** * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) * preserved when a node's constraints are degraded because validation does not enforce @@ -417,10 +436,10 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const defType = (schema as { _zod?: { def?: { type?: string } } })._zod?.def?.type; - if (defType !== undefined && NON_OBJECT_UNREPRESENTABLE_ROOTS.has(defType)) { + const defType = unwrappedZodDefType(schema); + if (defType !== undefined && NON_OBJECT_TYPELESS_ROOTS.has(defType)) { throw new Error( - `MCP tool and prompt schemas must describe objects (got an unrepresentable ${defType} schema). ` + + `MCP tool and prompt schemas must describe objects (got a non-object ${defType} schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } @@ -429,10 +448,15 @@ export function standardSchemaToJsonSchema( } /** - * Zod root types that `unrepresentable: 'any'` degrades to a typeless `{}` but which + * Zod root types that can emit a typeless `{}` under `unrepresentable: 'any'` but * provably do not describe objects — the input-root guard must keep rejecting them. + * `literal` reaches this branch only when typeless (an unrepresentable-value literal + * like `z.literal(undefined)`, or a mixed-type multi-value literal), neither of which + * describes an object; representable single-type literal roots emit an explicit + * `type` and are rejected by the earlier guard. `custom` is deliberately excluded — + * it can legitimately accept objects. */ -const NON_OBJECT_UNREPRESENTABLE_ROOTS: ReadonlySet = new Set([ +const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ 'bigint', 'symbol', 'map', @@ -440,9 +464,44 @@ const NON_OBJECT_UNREPRESENTABLE_ROOTS: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function' + 'function', + 'literal' ]); +/** Transparent wrapper def types whose `innerType` carries the real root semantics. */ +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch']); + +/** + * The innermost def type of a zod schema, unwrapped through transparent wrappers + * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch` via `innerType`, + * `lazy` via its getter) so `z.bigint().optional()` and `z.lazy(() => z.bigint())` + * report `'bigint'`. A seen-set bounds recursive lazies; non-zod schemas and + * throwing lazy getters yield `undefined`. + */ +function unwrappedZodDefType(schema: unknown): string | undefined { + const seen = new Set(); + let current = schema; + while (typeof current === 'object' && current !== null && !seen.has(current)) { + seen.add(current); + const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown } } })._zod?.def; + if (def === undefined || typeof def.type !== 'string') return undefined; + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + current = (def.getter as () => unknown)(); + } catch { + return undefined; + } + continue; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + current = def.innerType; + continue; + } + return def.type; + } + return undefined; +} + /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index a10fb472e2..c4816d2866 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -177,6 +177,18 @@ describe('zod conversion options (#2464)', () => { 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. @@ -188,6 +200,21 @@ describe('zod conversion options (#2464)', () => { 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/); + // Typeless literal roots (unrepresentable or mixed-type values) are not objects. + expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.literal(['a', 1]), 'input')).toThrow(/must describe objects/); + // Wrapped OBJECT roots stay accepted. + expect(standardSchemaToJsonSchema(z.object({ a: z.string() }).optional(), 'input').type).toBe('object'); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From 0cee9010f14132ea2c3112a20d6d7530eac520c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 21:19:13 +0000 Subject: [PATCH 09/16] fix(core-internal): position-independent catch degrade; unwrap pipe/promise roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The catch-degrade verdict must be position-independent: zod deduplicates reused schema instances and runs the override once per instance, so a .catch() shared between a nested position and a root(-composition) position got one verdict applied to both sites — nested-first broke the root object proof (2025-era legacy-wrap flip), root-first kept unenforced inner constraints at the nested copy (the #2464 client-rejection class). The degrade now always keeps the emitted 'type' (dropping properties/required/additionalProperties and other constraints), which preserves the root proof at every position; the position-dependent feedsRootObjectProof branch is removed and the stale JSDoc bullet reworded to the new rule. - unwrappedZodDefType now unwraps pipe nodes via their INPUT side (def.in — the side io: 'input' conversion and input validation consume; never def.out) and promise nodes via innerType, so z.bigint().transform(...), z.bigint().pipe(...), and z.promise(z.bigint()) roots throw the actionable 'must describe objects' error instead of becoming phantom {type: 'object'} tools. z.object({...}).transform(...) stays accepted, and bare standalone z.transform(fn) stays excluded like z.custom(). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 61 +++++++++---------- .../test/util/standardSchema.test.ts | 49 ++++++++++++++- 2 files changed, 75 insertions(+), 35 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 167a8c6ce5..e62538dc50 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -190,11 +190,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * - 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 (below the root) degrade to an unconstrained schema - * (annotations and the emitted `default` kept): catch-validation accepts any raw - * value — the fallback replaces it only in the parsed result, which the server - * never ships. The conversion root keeps its emitted shape so the 2025-era - * legacy-wrap decision is unchanged. + * - Output `.catch()` nodes drop their constraint keywords (`properties`, `required`, + * `additionalProperties`, …) but keep the emitted `type`, annotations, and + * `default`: catch-validation accepts any raw value — the fallback replaces it + * only in the parsed result, which the server never ships. `type` is kept at every + * position (zod deduplicates reused instances, so the verdict must be + * position-independent) and preserves the 2025-era legacy-wrap object proof at the + * root and in root-level compositions. * * Known residual gaps: * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` @@ -237,17 +239,19 @@ function zodConversionOptions(io: 'input' | 'output'): Pick): boolean { - for (let i = 0; i < path.length; i += 2) { - const key = path[i]; - if ((key !== 'anyOf' && key !== 'oneOf' && key !== 'allOf') || typeof path[i + 1] !== 'number') return false; - } - return true; -} - /** * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) * preserved when a node's constraints are degraded because validation does not enforce @@ -469,21 +460,23 @@ const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ -const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch']); +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch', 'promise']); /** * The innermost def type of a zod schema, unwrapped through transparent wrappers - * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch` via `innerType`, - * `lazy` via its getter) so `z.bigint().optional()` and `z.lazy(() => z.bigint())` - * report `'bigint'`. A seen-set bounds recursive lazies; non-zod schemas and - * throwing lazy getters yield `undefined`. + * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch`/`promise` via + * `innerType`, `lazy` via its getter, and `pipe` via its INPUT side `def.in` — the + * side `io: 'input'` conversion and input validation both consume) so + * `z.bigint().optional()`, `z.lazy(() => z.bigint())`, and + * `z.bigint().transform(...)` all report `'bigint'`. A seen-set bounds recursive + * lazies; non-zod schemas and throwing lazy getters yield `undefined`. */ function unwrappedZodDefType(schema: unknown): string | undefined { const seen = new Set(); let current = schema; while (typeof current === 'object' && current !== null && !seen.has(current)) { seen.add(current); - const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown } } })._zod?.def; + const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown } } })._zod?.def; if (def === undefined || typeof def.type !== 'string') return undefined; if (def.type === 'lazy' && typeof def.getter === 'function') { try { @@ -493,6 +486,10 @@ function unwrappedZodDefType(schema: unknown): string | undefined { } continue; } + if (def.type === 'pipe' && def.in !== undefined) { + current = def.in; + continue; + } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { current = def.innerType; continue; diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index c4816d2866..bb1853c0dd 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -159,15 +159,41 @@ describe('zod conversion options (#2464)', () => { // 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. + // The emitted `type` is kept: the verdict must be position-independent (zod + // deduplicates reused instances), and root positions need it for the 2025-era + // legacy-wrap object proof. const properties = result.properties as Record>; - expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' } }); - expect(properties.scalar).toEqual({ default: 0 }); + expect(properties.inner).toEqual({ description: 'lenient', default: { n: 'd' }, type: 'object' }); + expect(properties.scalar).toEqual({ default: 0, type: 'number' }); // `x-*` vendor extensions are annotation-only and must survive the degrade. - expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1 }); + expect(properties.annotated).toEqual({ default: 0, title: 't', 'x-ui': 1, type: 'number' }); // 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'); @@ -213,8 +239,25 @@ describe('zod conversion options (#2464)', () => { // Typeless literal roots (unrepresentable or mixed-type values) are not objects. expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); expect(() => standardSchemaToJsonSchema(z.literal(['a', 1]), 'input')).toThrow(/must describe objects/); + // 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/); // 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'); }); test('a required field whose transform throws on undefined stays required and does not crash', async () => { From 16fab7268524eeca2620e26843f8b1e8014b8c64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 22:10:53 +0000 Subject: [PATCH 10/16] fix(core-internal): composition-aware root guard, symbol/function required, catch-of-union skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The input-root guard now recurses into compositions via a new nonObjectTypelessRootType helper: a union root is rejected when EVERY member unwinds to a non-object typeless type (one representable object member keeps it accepted), an intersection when ANY side does (the value must satisfy both) — so z.union([z.bigint(), z.symbol()]) and z.intersection(z.bigint(), z.bigint()) throw the actionable root error again instead of becoming phantom {type: 'object'} tools. 'nonoptional' joins the transparent-wrapper set (safe: z.object({...}).nonoptional() emits an explicit type: 'object'). - fieldAcceptsMissingKey also returns true for fields whose unwrapped def type is 'symbol' or 'function': JSON.stringify drops Symbol- and function-valued keys from the payload by the same mechanism that drops undefined-valued keys, so no serialized result can carry them (covers both the object required-filter and the record branch). - The .catch() degrade reduces composition keywords (anyOf/oneOf/allOf, emitted when the catch wraps a union or intersection) to member type skeletons instead of deleting them: the catch node emits no 'type' key for these shapes, so the keep-type rule alone left the root typeless and flipped the 2025-era legacy wrap for previously-working registrations; the JSDoc bullet is updated to the full rule. Audited sibling shapes: z.file() roots already throw via the explicit type guard (representable as string/binary); z.never() and fully/mixed-representable non-object unions were silently stamped pre-PR too (status quo, not regressed here); catch-of-$ref roots were typeless pre-PR as well (no wrap change) and now correctly drop the unenforced $ref constraints; BigInt-valued output fields make JSON.stringify throw (a transport-level ship failure already noted for defaults in the changeset). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 124 ++++++++++++++++-- .../test/util/standardSchema.test.ts | 28 ++++ 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index e62538dc50..bcbb844b2f 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -191,12 +191,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * 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`, …) but keep the emitted `type`, annotations, and - * `default`: catch-validation accepts any raw value — the fallback replaces it - * only in the parsed result, which the server never ships. `type` is kept at every - * position (zod deduplicates reused instances, so the verdict must be - * position-independent) and preserves the 2025-era legacy-wrap object proof at the - * root and in root-level compositions. + * `additionalProperties`, …) but keep the emitted `type` (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 type signal is kept at every position (zod + * deduplicates reused instances, so the verdict must be position-independent) and + * preserves the 2025-era legacy-wrap object proof at the root and in root-level + * compositions. * * Known residual gaps: * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` @@ -240,17 +241,23 @@ function zodConversionOptions(io: 'input' | 'output'): Pick compositionTypeSkeleton(member)); + continue; + } delete ctx.jsonSchema[key]; } return; @@ -284,6 +291,25 @@ function zodConversionOptions(io: 'input' | 'output'): Pick { + if (typeof node !== 'object' || node === null) return {}; + const source = node as Record; + const skeleton: Record = {}; + if (source.type !== undefined) skeleton.type = source.type; + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(source[key])) { + skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + } + } + return skeleton; +} + /** * JSON Schema annotation-vocabulary keywords (plus `default`, itself an annotation) * preserved when a node's constraints are degraded because validation does not enforce @@ -316,6 +342,11 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { // keep the field required. const defType = field._zod.def.type; if (defType === 'default' || defType === 'prefault') return true; + // JSON.stringify drops Symbol- and function-valued keys from the serialized + // payload entirely (the same mechanism that drops undefined-valued keys), so + // such fields can never appear on the wire. + const unwrapped = unwrappedZodDefType(field); + if (unwrapped === 'symbol' || unwrapped === 'function') return true; try { const result = field['~standard'].validate(undefined); if (result instanceof Promise) { @@ -427,10 +458,10 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const defType = unwrappedZodDefType(schema); - if (defType !== undefined && NON_OBJECT_TYPELESS_ROOTS.has(defType)) { + const nonObjectType = nonObjectTypelessRootType(schema); + if (nonObjectType !== undefined) { throw new Error( - `MCP tool and prompt schemas must describe objects (got a non-object ${defType} schema). ` + + `MCP tool and prompt schemas must describe objects (got a non-object ${nonObjectType} schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } @@ -438,6 +469,62 @@ export function standardSchemaToJsonSchema( return { type: 'object', ...result }; } +/** + * The def type of a zod root that emits a typeless node yet provably cannot describe + * an object, or `undefined` when the root may. Unwraps via {@linkcode unwrappedZodDefType}'s + * wrapper rules, then also recurses into compositions: a union is non-object when EVERY + * member is (one representable object member keeps it accepted), an intersection when + * ANY side is (the value must satisfy both). The shared seen-set bounds recursive lazies. + */ +function nonObjectTypelessRootType(schema: unknown, seen: Set = new Set()): string | undefined { + let current = schema; + while (typeof current === 'object' && current !== null && !seen.has(current)) { + seen.add(current); + const def = ( + current as { + _zod?: { + def?: { + type?: string; + innerType?: unknown; + getter?: unknown; + in?: unknown; + options?: unknown; + left?: unknown; + right?: unknown; + }; + }; + } + )._zod?.def; + if (def === undefined || typeof def.type !== 'string') return undefined; + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + current = (def.getter as () => unknown)(); + } catch { + return undefined; + } + continue; + } + if (def.type === 'pipe' && def.in !== undefined) { + current = def.in; + continue; + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + current = def.innerType; + continue; + } + if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { + return def.options.every(option => nonObjectTypelessRootType(option, seen) !== undefined) ? 'union' : undefined; + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + return nonObjectTypelessRootType(def.left, seen) !== undefined || nonObjectTypelessRootType(def.right, seen) !== undefined + ? 'intersection' + : undefined; + } + return NON_OBJECT_TYPELESS_ROOTS.has(def.type) ? def.type : undefined; + } + return undefined; +} + /** * Zod root types that can emit a typeless `{}` under `unrepresentable: 'any'` but * provably do not describe objects — the input-root guard must keep rejecting them. @@ -460,7 +547,16 @@ const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ -const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set(['optional', 'nullable', 'readonly', 'default', 'prefault', 'catch', 'promise']); +const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set([ + 'optional', + 'nonoptional', + 'nullable', + 'readonly', + 'default', + 'prefault', + 'catch', + 'promise' +]); /** * The innermost def type of a zod schema, unwrapped through transparent wrappers diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index bb1853c0dd..9be1bbd2ce 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -250,6 +250,12 @@ describe('zod conversion options (#2464)', () => { /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( @@ -258,6 +264,28 @@ describe('zod conversion options (#2464)', () => { '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('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 required field whose transform throws on undefined stays required and does not crash', async () => { From 568ebcfb546334f5bb227ea5b61c05d93dfafd7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:01:53 +0000 Subject: [PATCH 11/16] fix(core-internal): correct composition member classification; object-only catch type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixes a regression introduced by the composition recursion: 'literal' in the reject set classified fully-representable members like z.literal('admin') as non-object, so z.union([z.literal('admin'), z.literal('member')]) — zod's idiomatic enum spelling, which converted and listed fine pre-PR — threw and failed the ENTIRE tools/list (and prompts/list) with -32603, the exact whole-list outage this change eliminates. A composition member now counts as non-object ONLY when it unwinds to a genuinely unrepresentable type (bigint/symbol/map/set/void/undefined/nan/ function) or to a literal whose values are unrepresentable or mixed-type; representable members (single-type literals, z.string(), z.null()) keep the composition accepted exactly as pre-PR. - Fixes the fail-open direction of the same recursion: the shared seen-set conflated cycle protection with the member verdict, so const b = z.bigint(); z.union([b, b]) returned a false may-be-object verdict. The guard now tracks only the current traversal path (ancestors), giving shared instances a real verdict at every occurrence while recursive lazies stay bounded. - The .catch() degrade keeps 'type' only when it is 'object': a non-object type is an unenforced constraint that rejects the wrong-typed raw values catch exists to tolerate, and the 2025-era legacy-wrap object proof only ever consumes type === 'object' (same rule in compositionTypeSkeleton). z.number().catch(0) now advertises {default: 0}. - Generalize the input-side residual-gap prose beyond z.date(): any REQUIRED unrepresentable input field (z.bigint()/z.map()/z.set()/ z.symbol()) makes the tool listed yet uncallable via JSON — use JSON-representable types or make the field optional (JSDoc + changeset). Co-Authored-By: Claude --- .changeset/zod-tojsonschema-wire-truthful.md | 10 +- .../core-internal/src/util/standardSchema.ts | 180 +++++++++++------- .../test/util/standardSchema.test.ts | 33 +++- 3 files changed, 142 insertions(+), 81 deletions(-) diff --git a/.changeset/zod-tojsonschema-wire-truthful.md b/.changeset/zod-tojsonschema-wire-truthful.md index e47269316a..b8ae480eb9 100644 --- a/.changeset/zod-tojsonschema-wire-truthful.md +++ b/.changeset/zod-tojsonschema-wire-truthful.md @@ -23,8 +23,12 @@ the sanitization hook on a schema reused both bare and via a `.describe()`/`.met 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 `z.date()` tool/prompt argument is advertised as `string`/`date-time` -but input validation still runs the raw zod schema, which rejects strings — use -`z.iso.date()`/`z.iso.datetime()` for date-valued inputs.) Elicitation is unaffected: +On the input side, a required tool/prompt argument of a type JSON cannot carry makes the +tool listed yet uncallable: `z.date()` is advertised as `string`/`date-time` and other +unrepresentable types (`z.bigint()`, `z.map()`, `z.set()`, `z.symbol()`) as an +unconstrained `{}`, but input validation still runs the raw zod schema, which rejects +every JSON payload — use a JSON-representable type such as `z.iso.date()`/ +`z.iso.datetime()`, `z.number()`, `z.record(...)`, or `z.array(...)`, or make the field +optional.) Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted form grammar cannot round-trip, including `z.date()`. diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index bcbb844b2f..069bc5b18f 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -191,19 +191,23 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * 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`, …) but keep the emitted `type` (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 type signal is kept at every position (zod - * deduplicates reused instances, so the verdict must be position-independent) and - * preserves the 2025-era legacy-wrap object proof at the root and in root-level + * `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: - * - Input schemas (tool `inputSchema`, prompt `argsSchema`) containing `z.date()` - * advertise `string`/`date-time`, but input validation still runs the raw zod - * schema, which rejects strings — such a tool is listed yet uncallable via JSON. - * Use `z.iso.date()`/`z.iso.datetime()` for date-valued inputs. + * - A REQUIRED input field (tool `inputSchema`, prompt `argsSchema`) of a type JSON + * cannot carry makes the tool listed yet uncallable: `z.date()` advertises + * `string`/`date-time` and other unrepresentable types (`z.bigint()`, `z.map()`, + * `z.set()`, `z.symbol()`) an unconstrained `{}`, but input validation still runs + * the raw zod schema, which rejects every JSON payload. Use a JSON-representable + * type (`z.iso.date()`/`z.iso.datetime()`, `z.number()`, `z.record(...)`, + * `z.array(...)`) or make the field optional. * - Dynamic catch values (`.catch(ctx => …)`) still throw inside zod's own * catchProcessor before this hook runs ("Dynamic catch values are not supported * in JSON Schema"), so one such tool still fails the entire `tools/list` — the @@ -241,19 +245,23 @@ function zodConversionOptions(io: 'input' | 'output'): Pick compositionTypeSkeleton(member)); continue; @@ -301,7 +309,8 @@ function compositionTypeSkeleton(node: unknown): Record { if (typeof node !== 'object' || node === null) return {}; const source = node as Record; const skeleton: Record = {}; - if (source.type !== undefined) skeleton.type = source.type; + // Only `type: 'object'` feeds the proof; any other type is an unenforced constraint. + if (source.type === 'object') skeleton.type = 'object'; for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { if (Array.isArray(source[key])) { skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); @@ -471,70 +480,98 @@ export function standardSchemaToJsonSchema( /** * The def type of a zod root that emits a typeless node yet provably cannot describe - * an object, or `undefined` when the root may. Unwraps via {@linkcode unwrappedZodDefType}'s - * wrapper rules, then also recurses into compositions: a union is non-object when EVERY - * member is (one representable object member keeps it accepted), an intersection when - * ANY side is (the value must satisfy both). The shared seen-set bounds recursive lazies. + * 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 whose values are unrepresentable or mixed-type — 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. `ancestors` tracks only the current + * traversal path, so a shared instance gets a real verdict at every occurrence while + * recursive lazies stay bounded. */ -function nonObjectTypelessRootType(schema: unknown, seen: Set = new Set()): string | undefined { - let current = schema; - while (typeof current === 'object' && current !== null && !seen.has(current)) { - seen.add(current); - const def = ( - current as { - _zod?: { - def?: { - type?: string; - innerType?: unknown; - getter?: unknown; - in?: unknown; - options?: unknown; - left?: unknown; - right?: unknown; - }; +function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet = new Set()): string | 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; + options?: unknown; + left?: unknown; + right?: unknown; + values?: unknown; }; - } - )._zod?.def; - if (def === undefined || typeof def.type !== 'string') return undefined; - if (def.type === 'lazy' && typeof def.getter === 'function') { - try { - current = (def.getter as () => unknown)(); - } catch { - return undefined; - } - continue; + }; } - if (def.type === 'pipe' && def.in !== undefined) { - current = def.in; - continue; + )._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 nonObjectTypelessRootType((def.getter as () => unknown)(), path); + } catch { + return undefined; } - if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - current = def.innerType; + } + if (def.type === 'pipe' && def.in !== undefined) { + return nonObjectTypelessRootType(def.in, path); + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return nonObjectTypelessRootType(def.innerType, path); + } + if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { + return def.options.every(option => nonObjectTypelessRootType(option, path) !== undefined) ? 'union' : undefined; + } + if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { + return nonObjectTypelessRootType(def.left, path) !== undefined || nonObjectTypelessRootType(def.right, path) !== undefined + ? 'intersection' + : undefined; + } + if (def.type === 'literal') { + return isNonObjectTypelessLiteral(def.values) ? 'literal' : undefined; + } + return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? def.type : undefined; +} + +/** + * Whether a literal's values make its node both typeless and non-object: any + * unrepresentable value (`undefined`, bigint, symbol) or a mixed-JSON-type value list. + * Single-type representable literals emit an explicit `type` and may legitimately ride + * compositions (`z.union([z.literal('admin'), z.literal('member')])`, zod's idiomatic + * enum spelling, must keep listing exactly as it did pre-#2464). + */ +function isNonObjectTypelessLiteral(values: unknown): boolean { + if (!Array.isArray(values) || values.length === 0) return true; + const jsonTypes = new Set(); + for (const value of values) { + if (value === null) { + jsonTypes.add('null'); continue; } - if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { - return def.options.every(option => nonObjectTypelessRootType(option, seen) !== undefined) ? 'union' : undefined; - } - if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - return nonObjectTypelessRootType(def.left, seen) !== undefined || nonObjectTypelessRootType(def.right, seen) !== undefined - ? 'intersection' - : undefined; + const valueType = typeof value; + if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { + return true; // undefined / bigint / symbol values are unrepresentable } - return NON_OBJECT_TYPELESS_ROOTS.has(def.type) ? def.type : undefined; + jsonTypes.add(valueType); } - return undefined; + return jsonTypes.size > 1; } /** - * Zod root types that can emit a typeless `{}` under `unrepresentable: 'any'` but - * provably do not describe objects — the input-root guard must keep rejecting them. - * `literal` reaches this branch only when typeless (an unrepresentable-value literal - * like `z.literal(undefined)`, or a mixed-type multi-value literal), neither of which - * describes an object; representable single-type literal roots emit an explicit - * `type` and are rejected by the earlier guard. `custom` is deliberately excluded — - * it can legitimately accept objects. + * 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 input-root guard must keep rejecting roots and compositions built from + * them. `custom` and bare `transform` are deliberately excluded (they can legitimately + * accept objects), and typeless literals are classified separately by value in + * {@linkcode isNonObjectTypelessLiteral}. */ -const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ +const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'bigint', 'symbol', 'map', @@ -542,8 +579,7 @@ const NON_OBJECT_TYPELESS_ROOTS: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function', - 'literal' + 'function' ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 9be1bbd2ce..b196a660d7 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -158,15 +158,16 @@ describe('zod conversion options (#2464)', () => { 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. - // The emitted `type` is kept: the verdict must be position-independent (zod - // deduplicates reused instances), and root positions need it for the 2025-era - // legacy-wrap object proof. + // 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, type: 'number' }); + 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, type: 'number' }); + 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']); }); @@ -268,6 +269,26 @@ describe('zod conversion options (#2464)', () => { 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('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'); From 591c054c74a19b372f536ebbf9b5e0e91ca677b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:47:11 +0000 Subject: [PATCH 12/16] fix(core-internal): classify date members and non-finite literals; unwind piped defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Composition member classification: 'date' joins NON_OBJECT_UNREPRESENTABLE_TYPES — a Date value can never be a JSON object, and at member position the rewritten string/date-time node nests inside anyOf/allOf where the explicit-type root guard cannot see it, so z.union([z.date(), z.date()]), z.union([z.date(), z.bigint()]), and z.intersection(z.object({...}), z.date()) were stamped as phantom {type: 'object'} tools (pre-PR each threw loudly). Bare date ROOTS are unaffected (they throw via the explicit-type guard after the rewrite). isNonObjectTypelessLiteral now also rejects non-finite number literal values (Infinity/-Infinity/NaN are typeof 'number' but cannot ride JSON). Mixed unions with a representable member (z.union([z.date(), z.string()])) stay accepted under the every-member rule. - fieldAcceptsMissingKey's structural default short-circuit read only the outermost def type, missing a default hidden inside a pipe: z.number().default(7).transform(async v => v) is outer-'pipe' with the ZodDefault at def.in, so the probe went async and the field wrongly stayed in the advertised output required list while async-capable validation fills the default. A new hasStructuralDefault helper unwinds pipe INPUT sides, lazies, and transparent wrappers looking for a default/prefault node (verified: every wrapper in the set fills the inner default on undefined). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 58 +++++++++++++++---- .../test/util/standardSchema.test.ts | 35 +++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 069bc5b18f..ad4f23869e 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -346,11 +346,11 @@ const ANNOTATION_JSON_SCHEMA_KEYWORDS: ReadonlySet = new Set([ 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 runs) — decide structurally, since an async stage - // (`.refine(async ...)`) would push the probe below to a Promise and wrongly - // keep the field required. - const defType = field._zod.def.type; - if (defType === 'default' || defType === 'prefault') return true; + // 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 default may hide + // inside a pipe's input side (`.default(7).transform(...)` is outer-`'pipe'`). + if (hasStructuralDefault(field)) return true; // JSON.stringify drops Symbol- and function-valued keys from the serialized // payload entirely (the same mechanism that drops undefined-valued keys), so // such fields can never appear on the wire. @@ -369,6 +369,37 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { } } +/** + * Whether the field's def chain carries a `default`/`prefault` node, unwinding pipe + * INPUT sides, lazies, and transparent wrappers — so + * `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, the ZodDefault + * at `def.in`) is recognized structurally. A missing key resolves against the pipe's + * input side first, where the default fills before any later stage runs. `ancestors` + * tracks the current traversal path so recursive lazies stay bounded. + */ +function hasStructuralDefault(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 } } })._zod?.def; + if (def === undefined || typeof def.type !== 'string') return false; + if (def.type === 'default' || def.type === 'prefault') return true; + const path = new Set(ancestors); + path.add(field); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + return hasStructuralDefault((def.getter as () => unknown)(), path); + } catch { + return false; + } + } + if (def.type === 'pipe' && def.in !== undefined) { + return hasStructuralDefault(def.in, path); + } + if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { + return hasStructuralDefault(def.innerType, path); + } + return false; +} + /** Options for {@linkcode standardSchemaToJsonSchema}. */ export interface StandardSchemaToJsonSchemaOptions { /** @@ -558,16 +589,22 @@ function isNonObjectTypelessLiteral(values: unknown): boolean { if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { return true; // undefined / bigint / symbol values are unrepresentable } + if (valueType === 'number' && !Number.isFinite(value as number)) { + return true; // Infinity / -Infinity / NaN cannot ride JSON either + } jsonTypes.add(valueType); } return jsonTypes.size > 1; } /** - * 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 input-root guard must keep rejecting roots and compositions built from - * them. `custom` and bare `transform` are deliberately excluded (they can legitimately + * Zod def types that are genuinely unrepresentable in JSON Schema and whose values can + * never be a JSON object — the input-root guard must keep rejecting roots and + * compositions built from them. Most degrade to a typeless `{}` under + * `unrepresentable: 'any'`; `date` is instead rewritten to `string`/`date-time` (so a + * bare date ROOT throws via the explicit-type guard), but a `Date` value can never be + * a JSON object either, so date composition MEMBERS classify as non-object here. + * `custom` and bare `transform` are deliberately excluded (they can legitimately * accept objects), and typeless literals are classified separately by value in * {@linkcode isNonObjectTypelessLiteral}. */ @@ -579,7 +616,8 @@ const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'void', 'undefined', 'nan', - 'function' + 'function', + 'date' ]); /** Transparent wrapper def types whose `innerType` carries the real root semantics. */ diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index b196a660d7..259d81620e 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -148,6 +148,26 @@ describe('zod conversion options (#2464)', () => { 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'), @@ -289,6 +309,21 @@ describe('zod conversion options (#2464)', () => { 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('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'); From 4a92accb7026ed76d336ab140630cfefb0afdf3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:26:17 +0000 Subject: [PATCH 13/16] fix(core-internal): skeletonize oneOf as anyOf; accept mixed representable literals; broaden tolerance detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oneOf skeletons: catch-of-discriminated-union output schemas (zod emits DUs as oneOf) kept the oneOf keyword while members reduced to identical {type: 'object'} skeletons, so every legitimate payload matched BOTH members and Ajv rejected 'must match exactly one schema in oneOf' — a strict regression vs pre-PR where the constrained members were disjoint. The degrade loop and compositionTypeSkeleton now emit oneOf skeletons under anyOf, the honest loosening once the discriminating constraints are stripped (wrap-neutral: isProvablyObjectShapedRoot treats the composition keywords identically). - isNonObjectTypelessLiteral: drop the jsonTypes.size > 1 branch — a mixed-but-REPRESENTABLE literal (z.literal(['a', 1]) emits a valid {enum: ['a', 1]}) listed silently pre-PR, and classifying it non-object made one such registration fail the entire tools/list, a round-10-introduced fail-closed regression. Only genuinely unrepresentable values (undefined/bigint/symbol/non-finite numbers) reject now, matching the loudness-parity contract. - hasStructuralDefault -> hasStructuralMissingKeyTolerance: recognize static .catch() BEFORE the wrapper unwind would step past it (any input, including undefined, is replaced by the fallback — tolerance by construction), and recurse into union def.options with ANY-member semantics (a union accepts a missing key whenever any member does; intersections deliberately excluded). Fixes catch/union-nested defaults with async stages staying in the advertised output required. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 68 +++++++++++-------- .../test/util/standardSchema.test.ts | 55 ++++++++++++++- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index ad4f23869e..89a797de5c 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -263,7 +263,14 @@ function zodConversionOptions(io: 'input' | 'output'): Pick compositionTypeSkeleton(member)); + const skeletons = (ctx.jsonSchema[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + // `oneOf` means EXACTLY one: with member constraints stripped, the + // skeletons are indistinguishable and every payload would match all + // of them — advertise the honest loosening `anyOf` instead + // (wrap-neutral: `isProvablyObjectShapedRoot` treats the + // composition keywords identically). + if (key === 'oneOf') delete ctx.jsonSchema[key]; + ctx.jsonSchema[key === 'oneOf' ? 'anyOf' : key] = skeletons; continue; } delete ctx.jsonSchema[key]; @@ -313,7 +320,9 @@ function compositionTypeSkeleton(node: unknown): Record { if (source.type === 'object') skeleton.type = 'object'; for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { if (Array.isArray(source[key])) { - skeleton[key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); + // Skeletonized `oneOf` members are indistinguishable, so exactly-one + // semantics would reject every payload — emit `anyOf` instead. + skeleton[key === 'oneOf' ? 'anyOf' : key] = (source[key] as unknown[]).map(member => compositionTypeSkeleton(member)); } } return skeleton; @@ -350,7 +359,7 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { // stage (`.refine(async ...)`, `.transform(async ...)`) would push the probe // below to a Promise and wrongly keep the field required. The default may hide // inside a pipe's input side (`.default(7).transform(...)` is outer-`'pipe'`). - if (hasStructuralDefault(field)) return true; + if (hasStructuralMissingKeyTolerance(field)) return true; // JSON.stringify drops Symbol- and function-valued keys from the serialized // payload entirely (the same mechanism that drops undefined-valued keys), so // such fields can never appear on the wire. @@ -370,32 +379,41 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { } /** - * Whether the field's def chain carries a `default`/`prefault` node, unwinding pipe - * INPUT sides, lazies, and transparent wrappers — so - * `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, the ZodDefault - * at `def.in`) is recognized structurally. A missing key resolves against the pipe's - * input side first, where the default fills before any later stage runs. `ancestors` - * tracks the current traversal path so recursive lazies stay bounded. + * Whether the field's def chain carries a node that tolerates a missing key by + * construction — `default`/`prefault` (the default fills) or a static `catch` (any + * input, including `undefined`, is replaced by the fallback) — unwinding pipe INPUT + * sides, lazies, transparent wrappers, and union members (ANY tolerant member + * suffices: zod tries members and the tolerant one succeeds; intersections must NOT + * get this treatment, since `undefined` would have to satisfy both sides). This + * recognizes `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, + * the ZodDefault at `def.in`) structurally, since an async stage pushes the + * validate-probe to a Promise. `ancestors` tracks the current traversal path so + * recursive lazies stay bounded. */ -function hasStructuralDefault(field: unknown, ancestors: ReadonlySet = new Set()): boolean { +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 } } })._zod?.def; + const def = (field as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; options?: unknown } } }) + ._zod?.def; if (def === undefined || typeof def.type !== 'string') return false; - if (def.type === 'default' || def.type === 'prefault') return true; + // `catch` must be recognized BEFORE the wrapper unwind below would step past it. + if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch') return true; const path = new Set(ancestors); path.add(field); if (def.type === 'lazy' && typeof def.getter === 'function') { try { - return hasStructuralDefault((def.getter as () => unknown)(), path); + return hasStructuralMissingKeyTolerance((def.getter as () => unknown)(), path); } catch { return false; } } if (def.type === 'pipe' && def.in !== undefined) { - return hasStructuralDefault(def.in, path); + return hasStructuralMissingKeyTolerance(def.in, path); + } + if (def.type === 'union' && Array.isArray(def.options)) { + return def.options.some(option => hasStructuralMissingKeyTolerance(option, path)); } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return hasStructuralDefault(def.innerType, path); + return hasStructuralMissingKeyTolerance(def.innerType, path); } return false; } @@ -571,20 +589,17 @@ function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet(); for (const value of values) { - if (value === null) { - jsonTypes.add('null'); - continue; - } + if (value === null) continue; const valueType = typeof value; if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { return true; // undefined / bigint / symbol values are unrepresentable @@ -592,9 +607,8 @@ function isNonObjectTypelessLiteral(values: unknown): boolean { if (valueType === 'number' && !Number.isFinite(value as number)) { return true; // Infinity / -Infinity / NaN cannot ride JSON either } - jsonTypes.add(valueType); } - return jsonTypes.size > 1; + return false; } /** diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 259d81620e..5fc97d0646 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -1,6 +1,7 @@ import * as z from 'zod/v4'; import { standardSchemaToJsonSchema } from '../../src/util/standardSchema'; +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider'; import { isNonObjectJsonSchemaRoot } from '../../src/wire/rev2025-11-25/legacyWrap'; describe('standardSchemaToJsonSchema', () => { @@ -257,9 +258,12 @@ describe('zod conversion options (#2464)', () => { 'input' ) ).toThrow(/must describe objects/); - // Typeless literal roots (unrepresentable or mixed-type values) are not objects. + // Literals with genuinely unrepresentable values are not objects. expect(() => standardSchemaToJsonSchema(z.literal(undefined), 'input')).toThrow(/must describe objects/); - expect(() => standardSchemaToJsonSchema(z.literal(['a', 1]), '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( @@ -344,6 +348,53 @@ describe('zod conversion options (#2464)', () => { 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('.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('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), name: z.string() }); const result = standardSchemaToJsonSchema(schema, 'output'); From 6018eae8c286702ee20b2327acba738d3ca33b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:13:15 +0000 Subject: [PATCH 14/16] fix(core-internal): broaden structural tolerance walk; loud/quiet root verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hasStructuralMissingKeyTolerance now also recognizes: symbol/function leaves (JSON.stringify drops such keys, now detected through union members and the record call site, not just single-child chains), any/unknown leaves (undefined-accepting by construction — previously only rescued by the sync probe, so an async stage kept them required), and defaults at a pipe's OUTPUT side (z.preprocess(fn, z.number().default(7)) puts the transform at def.in and the tolerant node at def.out). All checks err loosen-only; the now-redundant unwrappedZodDefType carve-out is removed. The throwing-transform crash pin keeps exercising the probe hardening via a z.custom pipe (no structural verdict), while the z.unknown-piped field now counts tolerant structurally. - The input-root guard's composition verdicts split into loud vs quiet (nonObjectTypelessRootVerdict + nonObjectLiteralLoudness): loud shapes made pre-#2464 conversion throw (bigint/symbol/date/..., literals with undefined/bigint/symbol values) and keep throwing; non-finite number literals (Infinity/-Infinity/NaN) are non-object but zod silently emits {type: 'number', const: null} for them, so compositions built SOLELY of non-finite literals — which listed silently pre-#2464 — keep listing instead of failing the whole tools/list. z.union([z.literal(Infinity), z.bigint()]) stays loud via the bigint co-member (pinned). Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 176 +++++++++--------- .../test/util/standardSchema.test.ts | 44 ++++- 2 files changed, 129 insertions(+), 91 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 89a797de5c..d610593367 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -357,14 +357,12 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { // 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 default may hide - // inside a pipe's input side (`.default(7).transform(...)` is outer-`'pipe'`). + // 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; - // JSON.stringify drops Symbol- and function-valued keys from the serialized - // payload entirely (the same mechanism that drops undefined-valued keys), so - // such fields can never appear on the wire. - const unwrapped = unwrappedZodDefType(field); - if (unwrapped === 'symbol' || unwrapped === 'function') return true; try { const result = field['~standard'].validate(undefined); if (result instanceof Promise) { @@ -379,24 +377,31 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { } /** - * Whether the field's def chain carries a node that tolerates a missing key by - * construction — `default`/`prefault` (the default fills) or a static `catch` (any - * input, including `undefined`, is replaced by the fallback) — unwinding pipe INPUT - * sides, lazies, transparent wrappers, and union members (ANY tolerant member - * suffices: zod tries members and the tolerant one succeeds; intersections must NOT - * get this treatment, since `undefined` would have to satisfy both sides). This - * recognizes `z.number().default(7).transform(async v => v)` (outer def `'pipe'`, - * the ZodDefault at `def.in`) structurally, since an async stage pushes the - * validate-probe to a Promise. `ancestors` tracks the current traversal path so + * 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), an undefined-accepting + * leaf (`z.any()`/`z.unknown()`), or a Symbol-/function-typed leaf (JSON.stringify + * drops such keys from the payload entirely) — unwinding pipe sides, lazies, + * transparent wrappers, and union members (ANY tolerant member suffices: zod tries + * members and the tolerant one succeeds; intersections must NOT get this treatment, + * since `undefined` would have to satisfy 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; options?: unknown } } }) - ._zod?.def; + const def = ( + field as { + _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown; out?: unknown; options?: unknown } }; + } + )._zod?.def; if (def === undefined || typeof def.type !== 'string') return false; // `catch` must be recognized BEFORE the wrapper unwind below would step past it. if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch') return true; + if (def.type === 'any' || def.type === 'unknown' || def.type === 'symbol' || def.type === 'function') return true; const path = new Set(ancestors); path.add(field); if (def.type === 'lazy' && typeof def.getter === 'function') { @@ -407,7 +412,14 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet } } if (def.type === 'pipe' && def.in !== undefined) { - return hasStructuralMissingKeyTolerance(def.in, path); + 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)); @@ -516,10 +528,12 @@ export function standardSchemaToJsonSchema( // so misregistered roots keep failing loudly instead of being advertised as // permanently-uncallable `{type: 'object'}` tools. if (result.type === undefined) { - const nonObjectType = nonObjectTypelessRootType(schema); - if (nonObjectType !== undefined) { + const verdict = nonObjectTypelessRootVerdict(schema); + // Quiet verdicts (compositions of only non-finite number literals) listed + // silently pre-#2464 and keep the unconditional stamp below. + if (verdict !== undefined && verdict.loud) { throw new Error( - `MCP tool and prompt schemas must describe objects (got a non-object ${nonObjectType} schema). ` + + `MCP tool and prompt schemas must describe objects (got a non-object ${verdict.type} schema). ` + `Wrap your schema in z.object({...}) or equivalent.` ); } @@ -528,19 +542,28 @@ export function standardSchemaToJsonSchema( } /** - * The def type of a zod root that emits a typeless node yet provably cannot describe + * 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 whose values are unrepresentable or mixed-type — 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. `ancestors` tracks only the current - * traversal path, so a shared instance gets a real verdict at every occurrence while - * recursive lazies stay bounded. + * type, or to a literal carrying unrepresentable values — representable members + * (including single-type literals, zod's idiomatic enum spelling, and representable + * non-object types like `z.string()`) keep the composition accepted, exactly as such + * roots converted before #2464. + * + * The verdict carries loudness for the loudness-parity contract: `loud` shapes made + * pre-#2464 conversion throw (bigint/symbol/date/…, literals with undefined/bigint/ + * symbol values) and must keep throwing; `quiet` shapes (non-finite number literals, + * which zod silently emits as `{type: 'number', const: null}`) listed silently + * pre-#2464 and must keep listing — the guard throws only for a loud-containing + * verdict. `ancestors` tracks only the current traversal path, so a shared instance + * gets a real verdict at every occurrence while recursive lazies stay bounded. */ -function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet = new Set()): string | undefined { +function nonObjectTypelessRootVerdict( + schema: unknown, + ancestors: ReadonlySet = new Set() +): { type: string; loud: boolean } | undefined { if (typeof schema !== 'object' || schema === null || ancestors.has(schema)) return undefined; const def = ( schema as { @@ -563,52 +586,68 @@ function nonObjectTypelessRootType(schema: unknown, ancestors: ReadonlySet unknown)(), path); + return nonObjectTypelessRootVerdict((def.getter as () => unknown)(), path); } catch { return undefined; } } if (def.type === 'pipe' && def.in !== undefined) { - return nonObjectTypelessRootType(def.in, path); + return nonObjectTypelessRootVerdict(def.in, path); } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - return nonObjectTypelessRootType(def.innerType, path); + return nonObjectTypelessRootVerdict(def.innerType, path); } if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) { - return def.options.every(option => nonObjectTypelessRootType(option, path) !== undefined) ? 'union' : undefined; + const verdicts = def.options.map(option => nonObjectTypelessRootVerdict(option, path)); + if (verdicts.includes(undefined)) return undefined; + return { type: 'union', loud: verdicts.some(verdict => verdict?.loud === true) }; } if (def.type === 'intersection' && def.left !== undefined && def.right !== undefined) { - return nonObjectTypelessRootType(def.left, path) !== undefined || nonObjectTypelessRootType(def.right, path) !== undefined - ? 'intersection' - : undefined; + const left = nonObjectTypelessRootVerdict(def.left, path); + const right = nonObjectTypelessRootVerdict(def.right, path); + if (left === undefined && right === undefined) return undefined; + return { type: 'intersection', loud: left?.loud === true || right?.loud === true }; } if (def.type === 'literal') { - return isNonObjectTypelessLiteral(def.values) ? 'literal' : undefined; + const loudness = nonObjectLiteralLoudness(def.values); + return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' }; } - return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? def.type : undefined; + return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined; } /** - * Whether a literal carries a genuinely unrepresentable value (`undefined`, bigint, - * symbol, or a non-finite number) — only those made pre-#2464 conversion fail loudly, - * so only those may reject here. Representable literals — including single-type ones + * The non-object loudness of a literal's values, or `undefined` when the literal may + * be JSON-satisfiable. `'loud'`: a value zod's own converter threw on pre-#2464 + * (`undefined`, bigint, symbol) — such roots must keep failing loudly. `'quiet'`: + * only non-finite numbers (`Infinity`/`-Infinity`/`NaN`), which zod silently emits + * as `{type: 'number', const: null}` — non-object, but such roots listed silently + * pre-#2464 and must keep listing. Any representable value (string, finite number, + * boolean, null) makes the literal satisfiable via JSON: `undefined` * (`z.union([z.literal('admin'), z.literal('member')])`, zod's idiomatic enum - * spelling) and mixed-type value lists (`z.literal(['a', 1])` emits a valid - * `{enum: ['a', 1]}`) — keep converting and listing exactly as they did pre-#2464. + * spelling, and mixed-type lists like `z.literal(['a', 1])` all keep converting + * exactly as they did pre-#2464). */ -function isNonObjectTypelessLiteral(values: unknown): boolean { - if (!Array.isArray(values) || values.length === 0) return true; +function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined { + if (!Array.isArray(values) || values.length === 0) return 'loud'; + let representable = false; + let nonFinite = false; for (const value of values) { - if (value === null) continue; + if (value === null) { + representable = true; + continue; + } const valueType = typeof value; if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { - return true; // undefined / bigint / symbol values are unrepresentable + return 'loud'; // undefined / bigint / symbol values threw pre-#2464 } if (valueType === 'number' && !Number.isFinite(value as number)) { - return true; // Infinity / -Infinity / NaN cannot ride JSON either + nonFinite = true; + } else { + representable = true; } } - return false; + if (representable) return undefined; + return nonFinite ? 'quiet' : undefined; } /** @@ -646,43 +685,6 @@ const WRAPPER_ZOD_DEF_TYPES: ReadonlySet = new Set([ 'promise' ]); -/** - * The innermost def type of a zod schema, unwrapped through transparent wrappers - * (`optional`/`nullable`/`readonly`/`default`/`prefault`/`catch`/`promise` via - * `innerType`, `lazy` via its getter, and `pipe` via its INPUT side `def.in` — the - * side `io: 'input'` conversion and input validation both consume) so - * `z.bigint().optional()`, `z.lazy(() => z.bigint())`, and - * `z.bigint().transform(...)` all report `'bigint'`. A seen-set bounds recursive - * lazies; non-zod schemas and throwing lazy getters yield `undefined`. - */ -function unwrappedZodDefType(schema: unknown): string | undefined { - const seen = new Set(); - let current = schema; - while (typeof current === 'object' && current !== null && !seen.has(current)) { - seen.add(current); - const def = (current as { _zod?: { def?: { type?: string; innerType?: unknown; getter?: unknown; in?: unknown } } })._zod?.def; - if (def === undefined || typeof def.type !== 'string') return undefined; - if (def.type === 'lazy' && typeof def.getter === 'function') { - try { - current = (def.getter as () => unknown)(); - } catch { - return undefined; - } - continue; - } - if (def.type === 'pipe' && def.in !== undefined) { - current = def.in; - continue; - } - if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { - current = def.innerType; - continue; - } - return def.type; - } - return undefined; -} - /** * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index 5fc97d0646..bb8a80573f 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -328,6 +328,16 @@ describe('zod conversion options (#2464)', () => { expect(standardSchemaToJsonSchema(z.union([z.date(), z.string()]), 'input').type).toBe('object'); }); + 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'); @@ -395,15 +405,41 @@ describe('zod conversion options (#2464)', () => { 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('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), name: z.string() }); + 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'); - // The missing-key probe cannot demonstrate tolerance (the transform throws on - // undefined; depending on the zod version the probe throws synchronously or + // `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(['n', 'name']); + expect(result.required).toEqual(['c', 'name']); await new Promise(resolve => setTimeout(resolve, 10)); }); From 803464573d6bc6a81502ea5c198cd073e5544d8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:58:39 +0000 Subject: [PATCH 15/16] fix(core-internal): preprocess-wrapped roots; optional/void/intersection tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nonObjectTypelessRootVerdict's pipe branch mirrored the opposite-pipe unwind already present in hasStructuralMissingKeyTolerance: when def.in unwinds to a 'transform' node with no verdict, the guard now recurses def.out — so z.preprocess(v => v, z.bigint()) roots (and such members inside compositions) throw the actionable 'must describe objects' error again instead of being stamped as phantom {type: 'object'} tools. Object-rooted preprocess stays accepted (explicit type: 'object' never reaches the guard) and z.preprocess(fn, z.date()) keeps throwing via the explicit-type guard. - hasStructuralMissingKeyTolerance now recognizes: 'optional' as tolerance-by-construction BEFORE the wrapper unwind would step past it (z.string().optional().transform(async v => v ?? 'x') stays in zod's output-io required while validation tolerates absence — the same bug class as the fixed catch case); 'void'/'undefined' leaves and literals whose value list includes undefined; and intersections with EVERY-side semantics (undefined must parse through BOTH sides — each default fills and zod merges the results). All loosen-only; covers the record call site via the shared predicate. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 54 ++++++++++++---- .../test/util/standardSchema.test.ts | 64 +++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index d610593367..59eab76d72 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -379,12 +379,13 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean { /** * 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), an undefined-accepting - * leaf (`z.any()`/`z.unknown()`), or a Symbol-/function-typed leaf (JSON.stringify - * drops such keys from the payload entirely) — unwinding pipe sides, lazies, - * transparent wrappers, and union members (ANY tolerant member suffices: zod tries - * members and the tolerant one succeeds; intersections must NOT get this treatment, - * since `undefined` would have to satisfy both sides). Deciding structurally matters + * 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 @@ -395,13 +396,30 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet 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 } }; + _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` must be recognized BEFORE the wrapper unwind below would step past it. - if (def.type === 'default' || def.type === 'prefault' || def.type === 'catch') return true; - if (def.type === 'any' || def.type === 'unknown' || def.type === 'symbol' || def.type === 'function') return true; + // `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') { @@ -424,6 +442,11 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet 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); } @@ -573,6 +596,7 @@ function nonObjectTypelessRootVerdict( innerType?: unknown; getter?: unknown; in?: unknown; + out?: unknown; options?: unknown; left?: unknown; right?: unknown; @@ -592,7 +616,15 @@ function nonObjectTypelessRootVerdict( } } if (def.type === 'pipe' && def.in !== undefined) { - return nonObjectTypelessRootVerdict(def.in, path); + const inVerdict = nonObjectTypelessRootVerdict(def.in, path); + if (inVerdict !== undefined) return inVerdict; + // `z.preprocess(fn, inner)` builds the opposite pipe — the transform sits at + // `def.in` (verdict undefined by design) and the real schema at `def.out`. + const inDef = (def.in as { _zod?: { def?: { type?: string } } })._zod?.def; + if (inDef?.type === 'transform' && def.out !== undefined) { + return nonObjectTypelessRootVerdict(def.out, path); + } + return undefined; } if (WRAPPER_ZOD_DEF_TYPES.has(def.type) && def.innerType !== undefined) { return nonObjectTypelessRootVerdict(def.innerType, path); diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index bb8a80573f..cf8e225dbd 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -328,6 +328,34 @@ describe('zod conversion options (#2464)', () => { 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 @@ -425,6 +453,42 @@ describe('zod conversion options (#2464)', () => { 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), From 98a3379c4b950ef48a830521d69bdfefa2d96dab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:36:00 +0000 Subject: [PATCH 16/16] fix(core-internal): loosen parent oneOf when a conversion degraded nodes; fix stale linkcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A discriminated union whose DISCRIMINATORS are .catch()-wrapped (legal zod; catch-wrapped members are rejected at construction) regressed: the nested catch degrade stripped each discriminator's type/const and the required-filter dropped 't', making the members mutually satisfiable while the untouched parent DU node kept oneOf — so Ajv (including the SDK's own Client.callTool re-validation) rejected every legitimate payload with 'must match exactly one schema in oneOf'. zodConversionOptions now takes a per-conversion loosened flag set by the catch degrade and by required-filter drops (object and record branches); when an output conversion was loosened anywhere, the epilogue rewrites every emitted oneOf to anyOf (loosen-only and wrap-neutral — isProvablyObjectShapedRoot treats the composition keywords identically). Untouched schemas are not loosened: a DU without degraded nodes keeps its oneOf (pinned). - Fix the stale {@linkcode isNonObjectTypelessLiteral} reference on NON_OBJECT_UNREPRESENTABLE_TYPES — the loud/quiet refactor renamed the helper to nonObjectLiteralLoudness. Co-Authored-By: Claude --- .../core-internal/src/util/standardSchema.ts | 45 +++++++++++++++++-- .../test/util/standardSchema.test.ts | 35 +++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index 59eab76d72..f74355e7c0 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -228,7 +228,10 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * round-trip, so the raw `Date` the server must ship reaches a validating client * as a `Date` instance and fails the advertised schema there. */ -function zodConversionOptions(io: 'input' | 'output'): Pick { +function zodConversionOptions( + io: 'input' | 'output', + loosened: { value: boolean } +): Pick { return { unrepresentable: 'any', override: ctx => { @@ -257,6 +260,7 @@ function zodConversionOptions(io: 'input' | 'output'): Pick !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; } @@ -306,6 +312,28 @@ function zodConversionOptions(io: 'input' | 'output'): Pick = new Set()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) rewriteOneOfToAnyOf(item, seen); + return; + } + const record = node as Record; + if (Array.isArray(record.oneOf) && record.anyOf === undefined) { + record.anyOf = record.oneOf; + delete record.oneOf; + } + for (const value of Object.values(record)) rewriteOneOfToAnyOf(value, seen); +} + /** * Reduces a degraded node's composition member to its type skeleton — `type` plus * recursively-skeletonized nested compositions, nothing else — so the output @@ -488,7 +516,8 @@ export function standardSchemaToJsonSchema( options?: StandardSchemaToJsonSchemaOptions ): Record { const std = schema['~standard']; - const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io); + const loosened = { value: false }; + const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io, loosened); let result: Record; if (std.jsonSchema) { result = std.jsonSchema[io]({ @@ -525,6 +554,16 @@ export function standardSchemaToJsonSchema( `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } + if (io === 'output' && loosened.value) { + // Exactly-one semantics cannot survive member loosening: once the catch + // degrade or the required-filter fired anywhere in this conversion, `oneOf` + // members (zod's discriminated-union emission) may have become mutually + // satisfiable — e.g. catch-wrapped discriminators — and Ajv would reject + // every payload with "must match exactly one schema in oneOf". Rewrite to + // the honest `anyOf` (loosen-only and wrap-neutral: + // `isProvablyObjectShapedRoot` treats the composition keywords identically). + rewriteOneOfToAnyOf(result); + } if (io === 'output') { // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or // not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is @@ -691,7 +730,7 @@ function nonObjectLiteralLoudness(values: unknown): 'loud' | 'quiet' | undefined * a JSON object either, so date composition MEMBERS classify as non-object here. * `custom` and bare `transform` are deliberately excluded (they can legitimately * accept objects), and typeless literals are classified separately by value in - * {@linkcode isNonObjectTypelessLiteral}. + * {@linkcode nonObjectLiteralLoudness}. */ const NON_OBJECT_UNREPRESENTABLE_TYPES: ReadonlySet = new Set([ 'bigint', diff --git a/packages/core-internal/test/util/standardSchema.test.ts b/packages/core-internal/test/util/standardSchema.test.ts index cf8e225dbd..9a3a699f0c 100644 --- a/packages/core-internal/test/util/standardSchema.test.ts +++ b/packages/core-internal/test/util/standardSchema.test.ts @@ -410,6 +410,41 @@ describe('zod conversion options (#2464)', () => { expect(nestedValidate({ res: { t: 'a', x: 'hello' }, name: 'n' }).valid).toBe(true); }); + test('a discriminated union with catch-wrapped discriminators loosens its oneOf to anyOf', () => { + // The catch degrade strips each discriminator's type/const and the + // required-filter drops 't', so the members become mutually satisfiable — + // the untouched parent's `oneOf` would then reject EVERY payload ("must + // match exactly one schema in oneOf"). + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a').catch('a'), x: z.string().optional() }), + z.object({ t: z.literal('b').catch('b'), y: z.string().optional() }) + ]); + + const root = standardSchemaToJsonSchema(du, 'output'); + expect(root.oneOf).toBeUndefined(); + expect(Array.isArray(root.anyOf)).toBe(true); + expect(new AjvJsonSchemaValidator().getValidator(root)({ t: 'a', x: 'v' }).valid).toBe(true); + + const nested = standardSchemaToJsonSchema(z.object({ res: du, name: z.string() }), 'output'); + const res = (nested.properties as Record>).res!; + expect(res.oneOf).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(nested)({ res: { t: 'a', x: 'v' }, name: 'n' }).valid).toBe(true); + }); + + test('a discriminated union without degraded nodes keeps its oneOf', () => { + // Untouched schemas must not be loosened: exactly-one semantics stay + // truthful while the members keep their discriminating constraints. + const du = z.discriminatedUnion('t', [ + z.object({ t: z.literal('a'), x: z.string() }), + z.object({ t: z.literal('b'), y: z.string() }) + ]); + const result = standardSchemaToJsonSchema(du, 'output'); + + expect(Array.isArray(result.oneOf)).toBe(true); + expect(result.anyOf).toBeUndefined(); + expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true); + }); + test('.catch() and union-nested defaults with async stages are dropped from output required', () => { const schema = z.object({ c: z