Skip to content

tools/list emits draft-07 $schema for Zod v4 schemas: toJsonSchemaCompat is called without a target, and mapMiniTarget defaults to 'draft-7' #2677

Description

@lgrolee

Describe the bug

tools/list advertises tool schemas with "$schema": "http://json-schema.org/draft-07/schema#", even when the tool is defined with Zod v4 — whose native toJSONSchema() emits 2020-12 by default.

Per SEP-1613, JSON Schema 2020-12 is the default dialect for embedded schemas in MCP messages. Because the SDK emits an explicit older dialect rather than omitting $schema, strict clients reject the tool definition outright — every tool on the server becomes unusable, and the failure happens before any tool call is dispatched.

This is distinct from #745 (closed): that issue concerned the Zod v3 path via zod-to-json-schema. The bug reported here is on the Zod v4 path, which is routed through zod/v4-mini's toJSONSchema but is explicitly downgraded to draft-7 by the SDK's own default.

To Reproduce

package.json:

json
{
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.30.0",
    "zod": "^4.4.3"
  }
}

server.mjs:

js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'repro', version: '1.0.0' });

server.registerTool('echo', {
description: 'Echo a message',
inputSchema: { message: z.string() },
outputSchema: { echoed: z.string() },
}, async ({ message }) => ({
content: [{ type: 'text', text: message }],
structuredContent: { echoed: message },
}));

await server.connect(new StdioServerTransport());

probe.mjs — sends initialize, then tools/list, and prints the advertised dialect:

js
import { spawn } from 'node:child_process';
const p = spawn('node', ['server.mjs'], { stdio: ['pipe', 'pipe', 'ignore'] });
let buf = '';
p.stdout.on('data', d => {
  buf += d.toString();
  for (const l of buf.split('\n')) {
    if (!l.trim().startsWith('{')) continue;
    let m; try { m = JSON.parse(l); } catch { continue; }
    if (m.id === 1) p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + '\n');
    if (m.id === 2) {
      const t = m.result.tools[0];
      console.log('inputSchema.$schema  =', t.inputSchema?.$schema);
      console.log('outputSchema.$schema =', t.outputSchema?.$schema);
      process.exit(0);
    }
  }
});
p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize',
  params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'p', version: '1' } } }) + '\n');

Run node probe.mjs.

Actual behavior

inputSchema.$schema  = http://json-schema.org/draft-07/schema#
outputSchema.$schema = http://json-schema.org/draft-07/schema#

For comparison, the same Zod v4 schema converted directly:

js
> toJSONSchema(z.object({ echoed: z.string() })).$schema
'https://json-schema.org/draft/2020-12/schema'

So the capability is already there — the SDK opts out of it.

Expected behavior

Either https://json-schema.org/draft/2020-12/schema, or no $schema key at all (which SEP-1613 makes equivalent to 2020-12).

Root cause

src/server/mcp.ts calls the conversion helper without a target:

ts
inputSchema: (() => {
  const obj = normalizeObjectSchema(tool.inputSchema);
  return obj
    ? toJsonSchemaCompat(obj, { strictUnions: true, pipeStrategy: 'input' })
    : EMPTY_OBJECT_JSON_SCHEMA;
})(),
// ...
toolDefinition.outputSchema = toJsonSchemaCompat(obj, {
  strictUnions: true,
  pipeStrategy: 'output',
});

and src/server/zod-json-schema-compat.ts maps "no target" to the legacy dialect:

ts
function mapMiniTarget(t) {
  if (!t) return 'draft-7';                                       // ← here
  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                                    // ← and here
}

Impact

Any server built on SDK 1.30.0 with Zod v4 is rejected wholesale by clients that enforce SEP-1613. Observed in the wild on obsidian-mcp-server 3.2.9 and 3.2.12, where all 12 tools failed with:

Tool 'obsidian_get_note' has an invalid outputSchema: JSON Schema declares an
unsupported dialect ("$schema": "http://json-schema.org/draft-07/schema#").
The default validator supports JSON Schema 2020-12 only.

There is no user-side workaround: upgrading the server package does not help (the SDK is a transitive dependency), and 1.30.0 is the latest published SDK. The only fix available today is patching dist/{esm,cjs}/server/zod-json-schema-compat.js in place, which is erased by any reinstall.

Suggested fix

Change the default in mapMiniTarget from 'draft-7' to 'draft-2020-12' for the Zod v4 branch, so the SDK matches SEP-1613 unless a caller explicitly asks for draft-7.

Patching only that default (both the if (!t) branch and the trailing fallback) is sufficient — verified: all 12 tools of the affected server then advertise 2020-12 and the client accepts them.

A more conservative variant would be to pass target: 'draft-2020-12' explicitly at the two toJsonSchemaCompat call sites in mcp.ts, leaving mapMiniTarget's default untouched for other callers. Either resolves the reported failure.

Environment

  |   -- | -- @modelcontextprotocol/sdk | 1.30.0 (latest published) zod | 4.4.3 zod-to-json-schema | 3.25.2 (transitive) Node.js | 24.14.0 OS | macOS Protocol version negotiated | 2025-06-18

Related

Describe the bug

tools/list advertises tool schemas with "$schema": "http://json-schema.org/draft-07/schema#", even when the tool is defined with Zod v4 — whose native toJSONSchema() emits 2020-12 by default.

Per SEP-1613, JSON Schema 2020-12 is the default dialect for embedded schemas in MCP messages. Because the SDK emits an explicit older dialect rather than omitting $schema, strict clients reject the tool definition outright — every tool on the server becomes unusable, and the failure happens before any tool call is dispatched.

This is distinct from #745 (closed): that issue concerned the Zod v3 path via zod-to-json-schema. The bug reported here is on the Zod v4 path, which is routed through zod/v4-mini's toJSONSchema but is explicitly downgraded to draft-7 by the SDK's own default.

To Reproduce

package.json:

json
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"zod": "^4.4.3"
}
}

server.mjs:

js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'repro', version: '1.0.0' });

server.registerTool('echo', {
description: 'Echo a message',
inputSchema: { message: z.string() },
outputSchema: { echoed: z.string() },
}, async ({ message }) => ({
content: [{ type: 'text', text: message }],
structuredContent: { echoed: message },
}));

await server.connect(new StdioServerTransport());

probe.mjs — sends initialize, then tools/list, and prints the advertised dialect:

js
import { spawn } from 'node:child_process';
const p = spawn('node', ['server.mjs'], { stdio: ['pipe', 'pipe', 'ignore'] });
let buf = '';
p.stdout.on('data', d => {
buf += d.toString();
for (const l of buf.split('\n')) {
if (!l.trim().startsWith('{')) continue;
let m; try { m = JSON.parse(l); } catch { continue; }
if (m.id === 1) p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + '\n');
if (m.id === 2) {
const t = m.result.tools[0];
console.log('inputSchema.$schema =', t.inputSchema?.$schema);
console.log('outputSchema.$schema =', t.outputSchema?.$schema);
process.exit(0);
}
}
});
p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'p', version: '1' } } }) + '\n');

Run node probe.mjs.

Actual behavior
inputSchema.$schema = http://json-schema.org/draft-07/schema#
outputSchema.$schema = http://json-schema.org/draft-07/schema#

For comparison, the same Zod v4 schema converted directly:

js

toJSONSchema(z.object({ echoed: z.string() })).$schema
'https://json-schema.org/draft/2020-12/schema'

So the capability is already there — the SDK opts out of it.

Expected behavior

Either https://json-schema.org/draft/2020-12/schema, or no $schema key at all (which SEP-1613 makes equivalent to 2020-12).

Root cause

src/server/mcp.ts calls the conversion helper without a target:

ts
inputSchema: (() => {
const obj = normalizeObjectSchema(tool.inputSchema);
return obj
? toJsonSchemaCompat(obj, { strictUnions: true, pipeStrategy: 'input' })
: EMPTY_OBJECT_JSON_SCHEMA;
})(),
// ...
toolDefinition.outputSchema = toJsonSchemaCompat(obj, {
strictUnions: true,
pipeStrategy: 'output',
});

and src/server/zod-json-schema-compat.ts maps "no target" to the legacy dialect:

ts
function mapMiniTarget(t) {
if (!t) return 'draft-7'; // ← here
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 // ← and here
}
Impact

Any server built on SDK 1.30.0 with Zod v4 is rejected wholesale by clients that enforce SEP-1613. Observed in the wild on obsidian-mcp-server 3.2.9 and 3.2.12, where all 12 tools failed with:

Tool 'obsidian_get_note' has an invalid outputSchema: JSON Schema declares an
unsupported dialect ("$schema": "http://json-schema.org/draft-07/schema#").
The default validator supports JSON Schema 2020-12 only.

There is no user-side workaround: upgrading the server package does not help (the SDK is a transitive dependency), and 1.30.0 is the latest published SDK. The only fix available today is patching dist/{esm,cjs}/server/zod-json-schema-compat.js in place, which is erased by any reinstall.

Suggested fix

Change the default in mapMiniTarget from 'draft-7' to 'draft-2020-12' for the Zod v4 branch, so the SDK matches SEP-1613 unless a caller explicitly asks for draft-7.

Patching only that default (both the if (!t) branch and the trailing fallback) is sufficient — verified: all 12 tools of the affected server then advertise 2020-12 and the client accepts them.

A more conservative variant would be to pass target: 'draft-2020-12' explicitly at the two toJsonSchemaCompat call sites in mcp.ts, leaving mapMiniTarget's default untouched for other callers. Either resolves the reported failure.

Environment

@modelcontextprotocol/sdk 1.30.0 (latest published)
zod 4.4.3
zod-to-json-schema 3.25.2 (transitive)
Node.js 24.14.0
OS macOS
Protocol version negotiated 2025-06-18
Related
#745 — same symptom on the Zod v3 path, closed; this report covers the v4 path.
SEP-1613 — establishes 2020-12 as the default dialect for embedded schemas.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Issues / PRs related to v1.x

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions