modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open
#2,677 8 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

bug v1
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

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.
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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the reproduction using package.json, server.mjs, and probe.mjs, then trace tools/list through toJsonSchemaCompat and mapMiniTarget. Done means Zod v4 schemas no longer advertise draft-07 and instead expose the expected JSON Schema 2020-12 dialect, with the reported tools/list behavior verified.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend-api-design
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.