From 82e819628e722f8cbef5c1f8c96f53b457634ab9 Mon Sep 17 00:00:00 2001 From: Reed Date: Wed, 13 May 2026 22:20:33 -0700 Subject: [PATCH 1/2] fix(server): emit JSON Schema 2020-12 in tools/list (SEP-1613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpServer's tools/list handler called toJsonSchemaCompat() without the target option, so mapMiniTarget(undefined) fell back to 'draft-7' and every inputSchema/outputSchema advertised draft-07, contradicting SEP-1613 / MCP 2025-11-25 spec. Pass target: 'draft-2020-12' at both call sites. For the Zod v4 branch, this surfaces native 2020-12 output (including prefixItems for tuples). For the Zod v3 branch — which uses zod-to-json-schema, a library with no 2020-12 target — overlay $schema in toJsonSchemaCompat when 2020-12 is requested; the emitted keyword subset (type, properties, required, additionalProperties, oneOf/anyOf/allOf, enum, const) is unchanged between draft-07 and 2020-12. Tests cover inputSchema and outputSchema $schema across both Zod versions, plus Zod v4 tuples emitting prefixItems. Fixes #2084 --- .changeset/fix-sep-1613-emit-2020-12.md | 5 ++ src/server/mcp.ts | 2 + src/server/zod-json-schema-compat.ts | 15 +++++- test/server/mcp.test.ts | 65 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-sep-1613-emit-2020-12.md diff --git a/.changeset/fix-sep-1613-emit-2020-12.md b/.changeset/fix-sep-1613-emit-2020-12.md new file mode 100644 index 0000000000..8f72b1a8ca --- /dev/null +++ b/.changeset/fix-sep-1613-emit-2020-12.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Fix SEP-1613 regression: `tools/list` now advertises `$schema: "https://json-schema.org/draft/2020-12/schema"` for `inputSchema` and `outputSchema`. The 1.29.0 release wired `target: 'draft-2020-12'` support into the Zod→JSON-Schema converter but the `McpServer` call sites in `mcp.ts` still emitted draft-07 because they omitted the `target` option. Both call sites now pass `target: 'draft-2020-12'`. Zod v4 schemas emit native 2020-12 (including `prefixItems` for tuples); Zod v3 schemas remain draft-07-shaped internally — `$schema` is overridden to advertise 2020-12, which is correct for the keyword subset that `zod-to-json-schema` emits. diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9fe0ed549c..a25b416b67 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -149,6 +149,7 @@ export class McpServer { const obj = normalizeObjectSchema(tool.inputSchema); return obj ? (toJsonSchemaCompat(obj, { + target: 'draft-2020-12', strictUnions: true, pipeStrategy: 'input' }) as Tool['inputSchema']) @@ -163,6 +164,7 @@ export class McpServer { const obj = normalizeObjectSchema(tool.outputSchema); if (obj) { toolDefinition.outputSchema = toJsonSchemaCompat(obj, { + target: 'draft-2020-12', strictUnions: true, pipeStrategy: 'output' }) as Tool['outputSchema']; diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index cde66b1772..8509604cfc 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -37,11 +37,22 @@ export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): }) as JsonSchema; } - // v3 branch — use vendored converter - return zodToJsonSchema(schema as z3.ZodTypeAny, { + // v3 branch — use vendored converter (emits draft-07) + const result = zodToJsonSchema(schema as z3.ZodTypeAny, { strictUnions: opts?.strictUnions ?? true, pipeStrategy: opts?.pipeStrategy ?? 'input' }) as JsonSchema; + + // SEP-1613: advertise draft/2020-12 when requested. zod-to-json-schema has + // no native 2020-12 target, but its emitted keywords (type, properties, + // required, additionalProperties, oneOf, anyOf, allOf, enum, const) are + // semantically unchanged in 2020-12. Tuple emission via `items: [...]` + // remains draft-07-shaped — upgrade to Zod v4 for native prefixItems. + if (mapMiniTarget(opts?.target) === 'draft-2020-12') { + result.$schema = 'https://json-schema.org/draft/2020-12/schema'; + } + + return result; } export function getMethodLiteral(schema: AnyObjectSchema): string { diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index 575d6a300e..e19512b464 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -6931,4 +6931,69 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { taskStore.cleanup(); }); }); + + describe('tools/list emits JSON Schema 2020-12 ($schema dialect — SEP-1613)', () => { + const DIALECT_2020_12 = 'https://json-schema.org/draft/2020-12/schema'; + + test('inputSchema advertises draft/2020-12 $schema', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + const client = new Client({ name: 'test client', version: '1.0' }); + + mcpServer.registerTool('echo', { inputSchema: { value: z.string() } }, async ({ value }) => ({ + content: [{ type: 'text', text: value }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + expect(result.tools[0].inputSchema.$schema).toBe(DIALECT_2020_12); + }); + + test('outputSchema advertises draft/2020-12 $schema', 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(), name: z.string() } + }, + async ({ id }) => ({ + content: [{ type: 'text', text: id }], + structuredContent: { id, name: 'x' } + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + expect(result.tools[0].outputSchema?.$schema).toBe(DIALECT_2020_12); + }); + + // Tuples are a 2020-12-only differentiator: draft-07 emits `items: [...]`, + // 2020-12 emits `prefixItems`. Zod v3 has no tuple → prefixItems path even + // under 2020-12 (it always emits draft-07-shaped items), so this assertion + // is meaningful only for Zod v4. + if (entry.isV4) { + test('Zod v4 tuples emit prefixItems under 2020-12', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + const client = new Client({ name: 'test client', version: '1.0' }); + + mcpServer.registerTool('point', { inputSchema: { coords: z.tuple([z.number(), z.number()]) } }, 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 coords = (result.tools[0].inputSchema.properties as Record).coords as Record; + expect(coords).toHaveProperty('prefixItems'); + expect(coords).not.toHaveProperty('items'); + }); + } + }); }); From 4f165997f835c48e94b038b3a8ea4864df0100e0 Mon Sep 17 00:00:00 2001 From: Reed Date: Wed, 13 May 2026 22:38:34 -0700 Subject: [PATCH 2/2] chore: prettier format changeset Wrap long line in .changeset/fix-sep-1613-emit-2020-12.md to satisfy the repo's prettier config. No semantic change. --- .changeset/fix-sep-1613-emit-2020-12.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-sep-1613-emit-2020-12.md b/.changeset/fix-sep-1613-emit-2020-12.md index 8f72b1a8ca..4ef800bbe1 100644 --- a/.changeset/fix-sep-1613-emit-2020-12.md +++ b/.changeset/fix-sep-1613-emit-2020-12.md @@ -2,4 +2,6 @@ '@modelcontextprotocol/sdk': patch --- -Fix SEP-1613 regression: `tools/list` now advertises `$schema: "https://json-schema.org/draft/2020-12/schema"` for `inputSchema` and `outputSchema`. The 1.29.0 release wired `target: 'draft-2020-12'` support into the Zod→JSON-Schema converter but the `McpServer` call sites in `mcp.ts` still emitted draft-07 because they omitted the `target` option. Both call sites now pass `target: 'draft-2020-12'`. Zod v4 schemas emit native 2020-12 (including `prefixItems` for tuples); Zod v3 schemas remain draft-07-shaped internally — `$schema` is overridden to advertise 2020-12, which is correct for the keyword subset that `zod-to-json-schema` emits. +Fix SEP-1613 regression: `tools/list` now advertises `$schema: "https://json-schema.org/draft/2020-12/schema"` for `inputSchema` and `outputSchema`. The 1.29.0 release wired `target: 'draft-2020-12'` support into the Zod→JSON-Schema converter but the `McpServer` call sites in +`mcp.ts` still emitted draft-07 because they omitted the `target` option. Both call sites now pass `target: 'draft-2020-12'`. Zod v4 schemas emit native 2020-12 (including `prefixItems` for tuples); Zod v3 schemas remain draft-07-shaped internally — `$schema` is overridden to +advertise 2020-12, which is correct for the keyword subset that `zod-to-json-schema` emits.