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..4ef800bbe1 --- /dev/null +++ b/.changeset/fix-sep-1613-emit-2020-12.md @@ -0,0 +1,7 @@ +--- +'@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'); + }); + } + }); });