Skip to content

Commit 034ccc1

Browse files
committed
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 <noreply@anthropic.com>
1 parent 3c067c4 commit 034ccc1

4 files changed

Lines changed: 108 additions & 11 deletions

File tree

packages/core-internal/src/shared/elicitation.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
2828

2929
function convertStandardElicitationSchema(schema: StandardSchemaWithJSON): Record<string, unknown> {
3030
try {
31-
return standardSchemaToJsonSchema(schema, 'input');
31+
// `unrepresentable: 'throw'`: the restricted form grammar must reject shapes it
32+
// cannot round-trip. A `z.date()` rewritten to `string`/`date-time` would pass the
33+
// wire checks, but the accepted response (a JSON string) could never satisfy the
34+
// same `z.date()` schema on handler re-entry — keep the documented loud failure
35+
// (`z.iso.date()`/`z.iso.datetime()` are the supported ways to elicit dates).
36+
return standardSchemaToJsonSchema(schema, 'input', { unrepresentable: 'throw' });
3237
} catch (error) {
3338
const detail = error instanceof Error ? error.message : String(error);
3439
throw new ProtocolError(

packages/core-internal/src/util/standardSchema.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,8 @@ function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaP
194194
override: ctx => {
195195
const def = ctx.zodSchema._zod.def;
196196
if (def.type === 'date') {
197-
for (const key of Object.keys(ctx.jsonSchema)) delete ctx.jsonSchema[key];
197+
// Under `unrepresentable: 'any'` the node carries only user annotations
198+
// (`.describe()` / `.meta()`) — keep them and stamp the wire shape beside them.
198199
ctx.jsonSchema.type = 'string';
199200
ctx.jsonSchema.format = 'date-time';
200201
return;
@@ -204,13 +205,13 @@ function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaP
204205
if (!isStrict && ctx.jsonSchema.additionalProperties === false) {
205206
delete ctx.jsonSchema.additionalProperties;
206207
}
207-
const properties = ctx.jsonSchema.properties;
208208
const required = ctx.jsonSchema.required;
209-
if (properties && Array.isArray(required)) {
210-
const filtered = required.filter(name => {
211-
const property = properties[name];
212-
return typeof property !== 'object' || property.default === undefined;
213-
});
209+
if (Array.isArray(required)) {
210+
// Keyed on the zod shape, not the emitted JSON: a registered `.default()`
211+
// hides its `default` keyword behind a `$ref`, and undefined-accepting
212+
// fields (`z.any()`, `z.unknown()`, …) never emit one yet may be dropped
213+
// from the wire payload by JSON.stringify.
214+
const filtered = required.filter(name => !fieldAcceptsMissingKey(def.shape[name]));
214215
if (filtered.length !== required.length) {
215216
if (filtered.length === 0) delete ctx.jsonSchema.required;
216217
else ctx.jsonSchema.required = filtered;
@@ -220,6 +221,19 @@ function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaP
220221
};
221222
}
222223

224+
/**
225+
* Whether a raw payload that omits this field still passes validation (zod treats a
226+
* missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`,
227+
* `z.unknown()`, `z.undefined()`, and unions with them), so the wire schema must not
228+
* advertise the field as `required`. Async validation cannot be awaited here; such
229+
* fields conservatively stay required.
230+
*/
231+
function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean {
232+
if (field === undefined) return false;
233+
const result = field['~standard'].validate(undefined);
234+
return !(result instanceof Promise) && result.issues === undefined;
235+
}
236+
223237
/**
224238
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
225239
*
@@ -231,14 +245,36 @@ function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaP
231245
* `io: 'output'` a non-object root is returned as-is; the `"object"` default is
232246
* applied only when the root is provably object-shaped.
233247
*/
234-
export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record<string, unknown> {
248+
export interface StandardSchemaToJsonSchemaOptions {
249+
/**
250+
* How types JSON Schema cannot represent (`z.date()`, `z.bigint()`, …) are handled
251+
* for zod schemas:
252+
*
253+
* - `'wire'` (default) — degrade gracefully: `z.date()` becomes
254+
* `{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` puts on the
255+
* wire for a `Date`) and other unrepresentable types become an unconstrained
256+
* schema, so one field cannot fail an entire `tools/list` response (#2464).
257+
* - `'throw'` — surface zod's conversion error. The elicitation path uses this: its
258+
* restricted form grammar must reject shapes it cannot round-trip, and a silently
259+
* rewritten `string`/`date-time` request would elicit a string that the original
260+
* `z.date()` schema can never re-validate on handler re-entry.
261+
*/
262+
unrepresentable?: 'wire' | 'throw';
263+
}
264+
265+
export function standardSchemaToJsonSchema(
266+
schema: StandardJSONSchemaV1,
267+
io: 'input' | 'output' = 'input',
268+
options?: StandardSchemaToJsonSchemaOptions
269+
): Record<string, unknown> {
235270
const std = schema['~standard'];
271+
const zodOptions = options?.unrepresentable === 'throw' ? undefined : zodConversionOptions(io);
236272
let result: Record<string, unknown>;
237273
if (std.jsonSchema) {
238274
result = std.jsonSchema[io]({
239275
target: JSON_SCHEMA_CONVERSION_TARGET,
240276
// Non-zod vendors receive no libraryOptions, so their behavior is unchanged.
241-
libraryOptions: std.vendor === 'zod' ? zodConversionOptions(io) : undefined
277+
libraryOptions: std.vendor === 'zod' ? zodOptions : undefined
242278
});
243279
} else if (std.vendor === 'zod') {
244280
// zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`).
@@ -261,7 +297,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
261297
result = z.toJSONSchema(schema as unknown as z.ZodType, {
262298
target: JSON_SCHEMA_CONVERSION_TARGET,
263299
io,
264-
...zodConversionOptions(io)
300+
...zodOptions
265301
}) as Record<string, unknown>;
266302
} else {
267303
throw new Error(

packages/core-internal/test/shared/inputRequired.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,18 @@ describe('inputRequired() builder', () => {
160160
requestedSchema: z.object({ role: z.union([z.literal('admin'), z.literal('member')]) })
161161
})
162162
).toThrow(TypeError);
163+
164+
// z.date() must keep failing loudly even though the tools-path conversion rewrites
165+
// it to string/date-time (#2464): the accepted response (a JSON string) could never
166+
// satisfy z.date() on handler re-entry, so acceptedContent() would silently return
167+
// undefined. z.iso.date()/z.iso.datetime() are the supported ways to elicit dates.
168+
const rejectDate = () =>
169+
inputRequired.elicit({
170+
message: 'When?',
171+
requestedSchema: z.object({ when: z.date() })
172+
});
173+
expect(rejectDate).toThrow(TypeError);
174+
expect(rejectDate).toThrow(/Date cannot be represented/);
163175
});
164176

165177
test.each([

packages/core-internal/test/util/standardSchema.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ describe('zod conversion options (#2464)', () => {
6060
expect((result.properties as Record<string, unknown>).big).toEqual({});
6161
});
6262

63+
test('z.date() keeps user annotations alongside the rewritten wire shape', () => {
64+
const result = standardSchemaToJsonSchema(z.object({ when: z.date().describe('event timestamp') }), 'input');
65+
66+
expect((result.properties as Record<string, unknown>).when).toEqual({
67+
type: 'string',
68+
format: 'date-time',
69+
description: 'event timestamp'
70+
});
71+
});
72+
73+
test("unrepresentable: 'throw' restores zod's conversion error (elicitation contract)", () => {
74+
expect(() => standardSchemaToJsonSchema(z.object({ when: z.date() }), 'input', { unrepresentable: 'throw' })).toThrow(
75+
/Date cannot be represented/
76+
);
77+
});
78+
6379
test('defaulted fields are not advertised as required in output schemas', () => {
6480
const schema = z.object({ counted: z.number().default(0), name: z.string() });
6581
const result = standardSchemaToJsonSchema(schema, 'output');
@@ -69,6 +85,34 @@ describe('zod conversion options (#2464)', () => {
6985
expect(result.required).toEqual(['name']);
7086
});
7187

88+
test('a registered .default() (emitted as $ref) is still dropped from output required', () => {
89+
const schema = z.object({ counted: z.number().default(0).meta({ id: 'StandardSchemaTestCounted' }), name: z.string() });
90+
const result = standardSchemaToJsonSchema(schema, 'output');
91+
92+
// The `default` keyword hides inside $defs behind a bare $ref; the filter must
93+
// key on the zod shape, not the emitted JSON.
94+
expect((result.properties as Record<string, Record<string, unknown>>).counted?.$ref).toBeDefined();
95+
expect(result.required).toEqual(['name']);
96+
});
97+
98+
test('a required field annotated with .meta({default}) stays required in output schemas', () => {
99+
const schema = z.object({ label: z.string().meta({ default: 'n/a' }), other: z.number() });
100+
const result = standardSchemaToJsonSchema(schema, 'output');
101+
102+
// The annotation carries a `default` keyword, but validation still requires the
103+
// field, so every shipped payload carries it.
104+
expect(result.required).toEqual(['label', 'other']);
105+
});
106+
107+
test('undefined-accepting fields are not advertised as required in output schemas', () => {
108+
const schema = z.object({ a: z.any(), u: z.unknown(), v: z.undefined(), name: z.string() });
109+
const result = standardSchemaToJsonSchema(schema, 'output');
110+
111+
// A raw payload with an undefined-valued key passes validation, and
112+
// JSON.stringify drops the key from the wire entirely.
113+
expect(result.required).toEqual(['name']);
114+
});
115+
72116
test('plain z.object() output schemas do not advertise additionalProperties:false', () => {
73117
const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output');
74118

0 commit comments

Comments
 (0)