Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-zod-v4-json-schema-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/sdk': patch
---

Fix hardcoded Zod v4 `toJSONSchema` options so tool schemas match the raw `structuredContent` the server actually sends:

- `z.date()` anywhere in a tool schema no longer throws and crashes `tools/list`; it is now rendered as `{ "type": "string", "format": "date-time" }`, matching what `JSON.stringify` puts on the wire for a `Date`.
- Output-schema fields with `.default()` are no longer advertised as `required`, and `additionalProperties: false` is no longer set on output schemas. The server never runs `structuredContent` through the schema's output transform, so a tool response that legitimately omits a
defaulted field or includes extra fields was being rejected by the client's own schema validation against the tool's advertised (but inaccurate) output schema.
25 changes: 23 additions & 2 deletions src/server/zod-json-schema-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,31 @@ function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft-

export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema {
if (isZ4Schema(schema)) {
// v4 branch — use Mini's built-in toJSONSchema
// v4 branch — use Mini's built-in toJSONSchema.
//
// `io` is always 'input' here, regardless of `opts.pipeStrategy`: the server
// never runs a tool's `structuredContent` through the schema's output
// transform/default-injection, it ships the tool's raw object as-is. So the
// advertised schema — for both input *and* output — must describe that raw
// shape: fields with `.default()` are optional (the caller/tool may omit
// them), matching zod's 'input' semantics rather than 'output' (which would
// mark them required, as if defaults had already been applied).
//
// `unrepresentable: 'any'` + the `override` below keep a single
// unsupported field (e.g. `z.date()`, which v4 refuses to represent by
// default) from crashing the entire `tools/list` response; `z.date()`
// specifically is rewritten to the RFC 3339 string format that
// `JSON.stringify` actually produces on the wire for a `Date`.
return z4mini.toJSONSchema(schema as z4c.$ZodType, {
target: mapMiniTarget(opts?.target),
io: opts?.pipeStrategy ?? 'input'
io: 'input',
unrepresentable: 'any',
override: ctx => {
if ((ctx.zodSchema as unknown as { _zod?: { def?: { type?: string } } })._zod?.def?.type === 'date') {
ctx.jsonSchema.type = 'string';
ctx.jsonSchema.format = 'date-time';
}
}
}) as JsonSchema;
}

Expand Down
70 changes: 70 additions & 0 deletions test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6931,4 +6931,74 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
taskStore.cleanup();
});
});

// Zod v4's toJSONSchema options were hardcoded (issue #2464): the server ships
// a tool's raw structuredContent object as-is, without running it through the
// schema's output transform, so the advertised schema must describe that raw
// shape rather than the fully-resolved (defaults-applied, closed) shape.
if (entry.isV4) {
describe('zod v4 toJSONSchema options (issue #2464)', () => {
test('z.date() in an input schema no longer crashes tools/list', async () => {
const mcpServer = new McpServer({ name: 'test server', version: '1.0' });
const client = new Client({ name: 'test client', version: '1.0' });

mcpServer.registerTool('schedule', { inputSchema: { when: z.date() } }, async () => ({
content: [{ type: 'text', text: 'ok' }]
}));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
const whenSchema = result.tools[0].inputSchema.properties?.when as Record<string, unknown>;
expect(whenSchema).toEqual({ type: 'string', format: 'date-time' });
});

test('a defaulted output field is not advertised as required, and a response omitting it validates', async () => {
const mcpServer = new McpServer({ name: 'test server', version: '1.0' });
const client = new Client({ name: 'test client', version: '1.0' });

mcpServer.registerTool(
'lookup',
{
inputSchema: { id: z.string() },
outputSchema: { id: z.string(), count: z.number().default(1) }
},
async ({ id }) => ({
content: [{ type: 'text', text: id }],
structuredContent: { id }
})
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

const listResult = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
const outputSchema = listResult.tools[0].outputSchema as { required?: string[] };
expect(outputSchema.required).not.toContain('count');

const callResult = await client.callTool({ name: 'lookup', arguments: { id: '42' } });
expect(callResult.isError).toBeFalsy();
});

test('an output schema does not set additionalProperties:false, so extra returned fields validate', async () => {
const mcpServer = new McpServer({ name: 'test server', version: '1.0' });
const client = new Client({ name: 'test client', version: '1.0' });

mcpServer.registerTool('extra', { inputSchema: {}, outputSchema: { id: z.string() } }, async () => ({
content: [{ type: 'text', text: 'ok' }],
structuredContent: { id: '1', extraField: 'surprise' }
}));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);

const listResult = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
expect(listResult.tools[0].outputSchema).not.toHaveProperty('additionalProperties');

const callResult = await client.callTool({ name: 'extra', arguments: {} });
expect(callResult.isError).toBeFalsy();
});
});
}
});
Loading