Skip to content

Commit d11d6fc

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

3 files changed

Lines changed: 85 additions & 27 deletions

File tree

.changeset/zod-tojsonschema-wire-truthful.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
'@modelcontextprotocol/server': patch
44
---
55

6-
Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or any
7-
unrepresentable type) in a registered tool's schema no longer throws during conversion and
8-
fails the entire `tools/list` response — dates are advertised as `{type: 'string', format:
9-
'date-time'}` (the shape `JSON.stringify` actually produces), and other unrepresentable
10-
types degrade to an unconstrained schema. Output schemas no longer advertise constraints the
11-
server doesn't enforce on the raw `structuredContent` it ships: `.default()`-carrying fields
12-
are dropped from `required`, and `additionalProperties: false` is dropped for plain
13-
`z.object()` (kept for `z.strictObject()`), so validating clients no longer reject legitimate
14-
tool results.
6+
Make zod-to-JSON-Schema conversion wire-truthful for tool schemas. A `z.date()` (or another
7+
unrepresentable type such as `z.bigint()`) in a registered tool's schema no longer throws
8+
during conversion and fails the entire `tools/list` response — dates are advertised as
9+
`{type: 'string', format: 'date-time'}` (the shape `JSON.stringify` actually produces), and
10+
other unrepresentable types degrade to an unconstrained schema. (BigInt values embedded as
11+
defaults or metadata, e.g. `.default(0n)`, still fail conversion — JSON cannot carry them.)
12+
Output schemas no longer advertise constraints the server doesn't enforce on the raw
13+
`structuredContent` it ships: fields that may be legitimately absent (`.default()`,
14+
undefined-accepting types) are dropped from `required` — on objects and enum-keyed records —
15+
and `additionalProperties: false` is dropped for plain `z.object()` (kept for
16+
`z.strictObject()`), so validating clients no longer reject legitimate tool results.
17+
Elicitation is unaffected: `inputRequired.elicit()` keeps throwing on schemas its restricted
18+
form grammar cannot round-trip, including `z.date()`.

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

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,16 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
180180
*
181181
* - `unrepresentable: 'any'`: a single unrepresentable type (e.g. `z.bigint()`) degrades
182182
* to an unconstrained `{}` instead of throwing and failing the entire `tools/list`.
183+
* (BigInt *values* embedded as defaults or metadata — `.default(0n)`, `.meta({default: 1n})`
184+
* — still throw: zod JSON-round-trips them in its own processors, outside this hook's reach.)
183185
* - `z.date()` is rewritten to `{type: 'string', format: 'date-time'}` — the shape
184186
* `JSON.stringify` actually produces for a `Date` (and what the zod 3 converter emitted).
185187
* - Output objects drop `additionalProperties: false` unless the object is strict:
186188
* zod validation tolerates unknown keys on plain `z.object()`, and the raw payload
187189
* ships them.
188-
* - Output objects drop `.default()`-carrying properties from `required`: zod fills
189-
* defaults during validation, but the shipped payload may legitimately omit them.
190+
* - Output objects and enum-keyed records drop properties that may be legitimately
191+
* absent from the shipped payload (`.default()`, undefined-accepting types) from
192+
* `required`: zod fills defaults during validation, but ships the raw object.
190193
*/
191194
function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaParams, 'unrepresentable' | 'override'> {
192195
return {
@@ -200,7 +203,16 @@ function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaP
200203
ctx.jsonSchema.format = 'date-time';
201204
return;
202205
}
203-
if (io !== 'output' || def.type !== 'object') return;
206+
if (io !== 'output') return;
207+
if (def.type === 'record') {
208+
// Enum-keyed records emit a `required` list too. Every key shares the one
209+
// value schema, so tolerance for missing keys is all-or-nothing.
210+
if (Array.isArray(ctx.jsonSchema.required) && fieldAcceptsMissingKey(def.valueType)) {
211+
delete ctx.jsonSchema.required;
212+
}
213+
return;
214+
}
215+
if (def.type !== 'object') return;
204216
const isStrict = def.catchall?._zod.def.type === 'never';
205217
if (!isStrict && ctx.jsonSchema.additionalProperties === false) {
206218
delete ctx.jsonSchema.additionalProperties;
@@ -225,26 +237,26 @@ function zodConversionOptions(io: 'input' | 'output'): Pick<z.core.ToJSONSchemaP
225237
* Whether a raw payload that omits this field still passes validation (zod treats a
226238
* missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`,
227239
* `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.
240+
* advertise the field as `required`. A probe that throws, rejects, or goes async (a
241+
* `.transform()` choking on `undefined` does all three depending on the zod version)
242+
* cannot demonstrate tolerance, so such fields conservatively stay required.
230243
*/
231244
function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean {
232245
if (field === undefined) return false;
233-
const result = field['~standard'].validate(undefined);
234-
return !(result instanceof Promise) && result.issues === undefined;
246+
try {
247+
const result = field['~standard'].validate(undefined);
248+
if (result instanceof Promise) {
249+
// Never leave a floating rejection: an unhandled one crashes the process.
250+
result.catch(() => {});
251+
return false;
252+
}
253+
return result.issues === undefined;
254+
} catch {
255+
return false;
256+
}
235257
}
236258

237-
/**
238-
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
239-
*
240-
* MCP requires `type: "object"` at the root of tool `inputSchema` and prompt
241-
* argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106).
242-
* Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`,
243-
* so for `io: 'input'` this function defaults `type` to `"object"` when absent
244-
* and throws on an explicit non-object `type` (e.g. `z.string()`). For
245-
* `io: 'output'` a non-object root is returned as-is; the `"object"` default is
246-
* applied only when the root is provably object-shaped.
247-
*/
259+
/** Options for {@linkcode standardSchemaToJsonSchema}. */
248260
export interface StandardSchemaToJsonSchemaOptions {
249261
/**
250262
* How types JSON Schema cannot represent (`z.date()`, `z.bigint()`, …) are handled
@@ -262,6 +274,17 @@ export interface StandardSchemaToJsonSchemaOptions {
262274
unrepresentable?: 'wire' | 'throw';
263275
}
264276

277+
/**
278+
* Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema.
279+
*
280+
* MCP requires `type: "object"` at the root of tool `inputSchema` and prompt
281+
* argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106).
282+
* Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`,
283+
* so for `io: 'input'` this function defaults `type` to `"object"` when absent
284+
* and throws on an explicit non-object `type` (e.g. `z.string()`). For
285+
* `io: 'output'` a non-object root is returned as-is; the `"object"` default is
286+
* applied only when the root is provably object-shaped.
287+
*/
265288
export function standardSchemaToJsonSchema(
266289
schema: StandardJSONSchemaV1,
267290
io: 'input' | 'output' = 'input',

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,37 @@ describe('zod conversion options (#2464)', () => {
113113
expect(result.required).toEqual(['name']);
114114
});
115115

116+
test('enum-keyed records with a defaulted value drop the emitted required list (output)', () => {
117+
const schema = z.object({ tallies: z.record(z.enum(['likes', 'shares']), z.number().default(0)), name: z.string() });
118+
const result = standardSchemaToJsonSchema(schema, 'output');
119+
120+
// zod fills record defaults during validation, so `{}` is a legitimate raw value
121+
// for the record node — but the record field itself stays required on the parent.
122+
const tallies = (result.properties as Record<string, Record<string, unknown>>).tallies!;
123+
expect(tallies.required).toBeUndefined();
124+
expect(result.required).toEqual(['tallies', 'name']);
125+
});
126+
127+
test('enum-keyed records with a strict value keep the emitted required list (output)', () => {
128+
const schema = z.record(z.enum(['likes', 'shares']), z.number());
129+
const result = standardSchemaToJsonSchema(schema, 'output');
130+
131+
// Validation rejects a missing key here, so the required list is truthful.
132+
expect(result.required).toEqual(['likes', 'shares']);
133+
});
134+
135+
test('a required field whose transform throws on undefined stays required and does not crash', async () => {
136+
const schema = z.object({ n: z.unknown().transform(v => (v as string).length), name: z.string() });
137+
const result = standardSchemaToJsonSchema(schema, 'output');
138+
139+
// The missing-key probe cannot demonstrate tolerance (the transform throws on
140+
// undefined; depending on the zod version the probe throws synchronously or
141+
// returns a rejecting Promise) — the field conservatively stays required, and
142+
// no unhandled rejection may escape (vitest fails the run on one).
143+
expect(result.required).toEqual(['n', 'name']);
144+
await new Promise(resolve => setTimeout(resolve, 10));
145+
});
146+
116147
test('plain z.object() output schemas do not advertise additionalProperties:false', () => {
117148
const result = standardSchemaToJsonSchema(z.object({ name: z.string() }), 'output');
118149

0 commit comments

Comments
 (0)