Summary
When tools use zod v4 schemas, the SDK's zod→JSON Schema conversion (server/zod-json-schema-compat.ts, toJsonSchemaCompat) calls zod's native toJSONSchema with hardcoded options and no passthrough, producing three behaviors that break real servers. All three stem from the same asymmetry: the SDK converts and validates with the tool's zod schema, but sends the tool's raw structuredContent object (validation does not replace/serialize the payload), so the advertised schema must describe the serialized form of a raw object.
Versions: @modelcontextprotocol/sdk 1.29.0, zod 4.4.3.
1. z.date() anywhere in a tool schema kills tools/list
zod v4's toJSONSchema defaults to unrepresentable: "throw", so listing tools throws:
McpError: MCP error -32603: Date cannot be represented in JSON Schema
The zod v3 path (vendored zod-to-json-schema) rendered z.date() as { "type": "string", "format": "date-time" } — which is also what JSON.stringify actually puts on the wire for a Date. Servers migrating v3→v4 with Date-valued fields (e.g. ORM entities) lose tools/list entirely. Switching schemas to z.iso.datetime() is not a workaround: server-side output validation then rejects the tools' raw Date values before serialization (isError: true, "expected string").
2. Defaulted output-schema fields are advertised as required
With io: "output" semantics, zod v4 lists .default() fields in required. But the server ships the tool's raw return value — zod never fills defaults into the response — so a tool that omits a defaulted field violates its own advertised schema, and spec-compliant validating clients reject the response. (zod-to-json-schema kept defaulted fields optional.)
3. additionalProperties: false on output objects can't be honored
Plain z.object() converts with additionalProperties: false on outputs, while zod validation tolerates and passes through unknown keys on the raw object. Any tool returning extra keys (very common when returning spread ORM/service objects) produces responses that validating clients reject. We caught four production tools failing this way once we added client-side ajv validation of serialized results.
Repro (issues 1 & 2 in one tool)
const server = new McpServer({ name: "repro", version: "1.0.0" });
server.registerTool(
"t",
{
description: "repro",
inputSchema: { when: z.date() }, // (1) tools/list throws
outputSchema: { counted: z.number().default(0), // (2) advertised as required
name: z.string() },
},
async () => ({ content: [], structuredContent: { name: "x" } }) // omits `counted` → strict clients reject
);
Proposal
Either (preferred, minimal) expose conversion options on registerTool/server config — at least unrepresentable and override passthrough to toJSONSchema — or make the defaults wire-truthful:
unrepresentable: "any" + render ZodDate as { type: "string", format: "date-time" } (matches serialization and the v3 path),
- drop defaulted fields from
required in output conversion,
- drop
additionalProperties: false in output conversion (or strip unknown keys during output validation so the promise is actually kept).
We currently carry exactly these three adjustments as a ~30-line pnpm patch to zod-json-schema-compat (both dists), with contract tests pinning the behaviors — happy to send a PR if maintainers agree on the direction.
Summary
When tools use zod v4 schemas, the SDK's zod→JSON Schema conversion (
server/zod-json-schema-compat.ts,toJsonSchemaCompat) calls zod's nativetoJSONSchemawith hardcoded options and no passthrough, producing three behaviors that break real servers. All three stem from the same asymmetry: the SDK converts and validates with the tool's zod schema, but sends the tool's rawstructuredContentobject (validation does not replace/serialize the payload), so the advertised schema must describe the serialized form of a raw object.Versions:
@modelcontextprotocol/sdk1.29.0,zod4.4.3.1.
z.date()anywhere in a tool schema killstools/listzod v4's
toJSONSchemadefaults tounrepresentable: "throw", so listing tools throws:The zod v3 path (vendored zod-to-json-schema) rendered
z.date()as{ "type": "string", "format": "date-time" }— which is also whatJSON.stringifyactually puts on the wire for aDate. Servers migrating v3→v4 withDate-valued fields (e.g. ORM entities) losetools/listentirely. Switching schemas toz.iso.datetime()is not a workaround: server-side output validation then rejects the tools' rawDatevalues before serialization (isError: true, "expected string").2. Defaulted output-schema fields are advertised as
requiredWith
io: "output"semantics, zod v4 lists.default()fields inrequired. But the server ships the tool's raw return value — zod never fills defaults into the response — so a tool that omits a defaulted field violates its own advertised schema, and spec-compliant validating clients reject the response. (zod-to-json-schema kept defaulted fields optional.)3.
additionalProperties: falseon output objects can't be honoredPlain
z.object()converts withadditionalProperties: falseon outputs, while zod validation tolerates and passes through unknown keys on the raw object. Any tool returning extra keys (very common when returning spread ORM/service objects) produces responses that validating clients reject. We caught four production tools failing this way once we added client-side ajv validation of serialized results.Repro (issues 1 & 2 in one tool)
Proposal
Either (preferred, minimal) expose conversion options on
registerTool/server config — at leastunrepresentableandoverridepassthrough totoJSONSchema— or make the defaults wire-truthful:unrepresentable: "any"+ renderZodDateas{ type: "string", format: "date-time" }(matches serialization and the v3 path),requiredin output conversion,additionalProperties: falsein output conversion (or strip unknown keys during output validation so the promise is actually kept).We currently carry exactly these three adjustments as a ~30-line pnpm patch to
zod-json-schema-compat(both dists), with contract tests pinning the behaviors — happy to send a PR if maintainers agree on the direction.