Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-sep-1613-emit-2020-12.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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'];
Expand Down
15 changes: 13 additions & 2 deletions src/server/zod-json-schema-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).coords as Record<string, unknown>;
expect(coords).toHaveProperty('prefixItems');
expect(coords).not.toHaveProperty('items');
});
}
});
});
Loading