From 02719679898f9e9ed5a566ab9eaccae3b57ab14d Mon Sep 17 00:00:00 2001 From: Chakshu Dhannawat Date: Thu, 9 Jul 2026 10:24:24 +0900 Subject: [PATCH 1/2] Fix hardcoded zod v4 toJSONSchema options breaking tool schemas zod-json-schema-compat.ts called zod v4's toJSONSchema with hardcoded options and no passthrough for the asymmetry between the schema used for validation and the raw structuredContent object the server actually ships (validation never replaces or serializes the tool's returned object). This caused three real defects: - z.date() anywhere in a tool schema throws during conversion, since v4 defaults unrepresentable types to 'throw'. That crashes the entire tools/list response, not just the one tool. - Output schema fields with .default() were listed in required even though the server never runs structuredContent through the schema's output transform, so a tool response correctly omitting a defaulted field got rejected by the client's own schema validation. - additionalProperties: false was set on output schemas the server doesn't actually enforce, so a tool response with extra fields also got rejected client-side. Fixed by always using io: 'input' semantics for the v4 branch (matching what the server actually ships, for both input and output schemas), adding unrepresentable: 'any' so an unsupported type degrades gracefully instead of crashing tools/list, and overriding z.date() specifically to the date-time string format JSON.stringify already produces on the wire. Added regression tests for all three cases, gated to the v4 matrix entry since the v3 vendored converter is a separate code path this doesn't touch. --- .changeset/fix-zod-v4-json-schema-options.md | 8 +++ src/server/zod-json-schema-compat.ts | 25 ++++++- test/server/mcp.test.ts | 70 ++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-zod-v4-json-schema-options.md 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..c898baefa7 --- /dev/null +++ b/.changeset/fix-zod-v4-json-schema-options.md @@ -0,0 +1,8 @@ +--- +'@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(); + }); + }); + } }); From b8301bf400cfc50a0d965113fbc50137bf704fd6 Mon Sep 17 00:00:00 2001 From: Chakshu Dhannawat Date: Thu, 9 Jul 2026 11:04:36 +0900 Subject: [PATCH 2/2] Fix changeset formatting --- .changeset/fix-zod-v4-json-schema-options.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-zod-v4-json-schema-options.md b/.changeset/fix-zod-v4-json-schema-options.md index c898baefa7..1831b2de33 100644 --- a/.changeset/fix-zod-v4-json-schema-options.md +++ b/.changeset/fix-zod-v4-json-schema-options.md @@ -5,4 +5,5 @@ 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. +- 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.