diff --git a/.changeset/default-json-schema-dialect-2020-12.md b/.changeset/default-json-schema-dialect-2020-12.md new file mode 100644 index 0000000000..6b2d68f8ea --- /dev/null +++ b/.changeset/default-json-schema-dialect-2020-12.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Default the Zod v4 JSON Schema conversion target to `draft-2020-12` instead of `draft-7`, so generated `inputSchema`/`outputSchema` declare `https://json-schema.org/draft/2020-12/schema`. The unrecognised-target fallback now agrees with that default instead of returning +`draft-7`. This matches the dialect the spec documents for `Tool.outputSchema`, Zod v4's own `toJSONSchema` default, and the v2 SDK. Passing an explicit `target` (including `'draft-7'`) is unchanged. diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index cde66b1772..587119651d 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -22,10 +22,10 @@ type CommonOpts = { }; function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft-2020-12' { - if (!t) return 'draft-7'; + if (!t) return 'draft-2020-12'; if (t === 'jsonSchema7' || t === 'draft-7') return 'draft-7'; if (t === 'jsonSchema2019-09' || t === 'draft-2020-12') return 'draft-2020-12'; - return 'draft-7'; // fallback + return 'draft-2020-12'; // fallback } export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { diff --git a/test/server/zod-json-schema-compat.test.ts b/test/server/zod-json-schema-compat.test.ts new file mode 100644 index 0000000000..9d10f2d836 --- /dev/null +++ b/test/server/zod-json-schema-compat.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4'; + +import { Client } from '../../src/client/index.js'; +import { InMemoryTransport } from '../../src/inMemory.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { toJsonSchemaCompat } from '../../src/server/zod-json-schema-compat.js'; +import { ListToolsResultSchema } from '../../src/types.js'; + +const DRAFT_2020_12 = 'https://json-schema.org/draft/2020-12/schema'; +const DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; + +describe('toJsonSchemaCompat dialect selection (Zod v4)', () => { + const schema = z4.object({ value: z4.string() }); + + it('defaults to JSON Schema 2020-12 when no target is given', () => { + // Tool.outputSchema in spec.types.ts states it "Defaults to JSON Schema + // 2020-12 when no explicit $schema is provided", and Zod v4's own + // toJSONSchema default target is draft-2020-12. + expect(toJsonSchemaCompat(schema)['$schema']).toBe(DRAFT_2020_12); + }); + + it('still honours an explicit draft-7 target', () => { + expect(toJsonSchemaCompat(schema, { target: 'draft-7' })['$schema']).toBe(DRAFT_07); + expect(toJsonSchemaCompat(schema, { target: 'jsonSchema7' })['$schema']).toBe(DRAFT_07); + }); + + it('honours an explicit 2020-12 target', () => { + expect(toJsonSchemaCompat(schema, { target: 'draft-2020-12' })['$schema']).toBe(DRAFT_2020_12); + expect(toJsonSchemaCompat(schema, { target: 'jsonSchema2019-09' })['$schema']).toBe(DRAFT_2020_12); + }); + + it('falls back to 2020-12 for an unrecognised target', () => { + // Unreachable through the CommonOpts type, but reachable from JavaScript + // callers; the fallback should agree with the documented default rather + // than silently downgrading the dialect. + const opts = { target: 'openApi3' } as unknown as Parameters[1]; + expect(toJsonSchemaCompat(schema, opts)['$schema']).toBe(DRAFT_2020_12); + }); +}); + +describe('toJsonSchemaCompat dialect selection (Zod v3)', () => { + it('remains on draft-07, which the vendored converter cannot change', () => { + // The v3 branch delegates to zod-to-json-schema, whose targets are + // jsonSchema7 / jsonSchema2019-09 / openApi3 — it has no 2020-12 target. + // Documented here so the v3/v4 asymmetry is intentional and visible. + const schema = z3.object({ value: z3.string() }); + expect(toJsonSchemaCompat(schema)['$schema']).toBe(DRAFT_07); + }); +}); + +describe('tools/list declared dialect', () => { + it('advertises 2020-12 for inputSchema and outputSchema built from Zod v4', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + mcpServer.registerTool( + 'echo', + { + description: 'Echoes its input', + inputSchema: { value: z4.string() }, + outputSchema: { value: z4.string() } + }, + async ({ value }) => ({ + content: [{ type: 'text', text: value as string }], + structuredContent: { value } + }) + ); + + const client = new Client({ name: 'test client', version: '1.0' }); + 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).toHaveLength(1); + expect(result.tools[0].inputSchema.$schema).toBe(DRAFT_2020_12); + expect(result.tools[0].outputSchema?.$schema).toBe(DRAFT_2020_12); + }); +});