diff --git a/.changeset/fix-zod-v4-json-schema-options.md b/.changeset/fix-zod-v4-json-schema-options.md new file mode 100644 index 0000000000..1831b2de33 --- /dev/null +++ b/.changeset/fix-zod-v4-json-schema-options.md @@ -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. diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index cde66b1772..ec2ed3f5c0 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -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; } diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index 575d6a300e..ccec0beb0b 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -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; + 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(); + }); + }); + } });