-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Description
Summary
When registering a tool with z.discriminatedUnion() as the inputSchema via registerTool(), the MCP SDK silently discards the entire schema and sends {"type":"object","properties":{}} to clients. The model receives no schema information and must infer all parameters from description text alone. There is no error, no warning, and no TypeScript compile-time indication that anything is wrong.
Relationship to #1585
Issue #1585 describes the same silent-drop symptom via the deprecated server.tool() API when passing plain JSON Schema objects. This issue describes the same outcome via the current registerTool() API when passing a valid Zod schema — specifically z.discriminatedUnion(). The root cause is different but the effect is identical.
Root Cause
In server/mcp.js (lines 75–82), normalizeObjectSchema() gates access to toJsonSchemaCompat():
inputSchema: (() => {
const obj = normalizeObjectSchema(tool.inputSchema);
return obj
? toJsonSchemaCompat(obj, { strictUnions: true, pipeStrategy: 'input' })
: EMPTY_OBJECT_JSON_SCHEMA; // ← discriminated unions fall here
})()In server/zod-compat.js, normalizeObjectSchema() only returns the schema if _zod.def.type === 'object':
if (isZ4Schema(schema)) {
const def = v4Schema._zod?.def;
if (def && (def.type === 'object' || def.shape !== undefined)) {
return schema; // only z.object() passes through
}
}
return undefined; // z.discriminatedUnion() hits this → EMPTY_OBJECT_JSON_SCHEMAFor z.discriminatedUnion(), def.type === 'union', so the function returns undefined and the empty fallback is used. Zod v4's own z.toJSONSchema() handles discriminated unions correctly (producing oneOf), but it is never called because normalizeObjectSchema blocks the schema before it reaches toJsonSchemaCompat.
Reproduction
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
const server = new McpServer({ name: 'test', version: '1.0' })
server.registerTool('get_item', {
description: 'Get an item by type',
inputSchema: z.discriminatedUnion('type', [
z.object({ type: z.literal('page'), search: z.string() }),
z.object({ type: z.literal('quiz'), search: z.string() }),
]),
}, async (args) => {
return { content: [{ type: 'text', text: JSON.stringify(args) }] }
})
// Client calls tools/list — inputSchema received:
// { "type": "object", "properties": {} }
//
// Expected:
// { "oneOf": [
// { "type": "object", "properties": { "type": { "const": "page" }, "search": { "type": "string" } }, "required": ["type", "search"] },
// { "type": "object", "properties": { "type": { "const": "quiz" }, "search": { "type": "string" } }, "required": ["type", "search"] }
// ]}Verification Method
We discovered this by calling count_tokens on each tool individually and inspecting the raw JSON payloads sent to the API. Tools using z.discriminatedUnion() consistently showed "properties": {} in the serialized schema. Calling z.toJSONSchema(discriminatedUnionSchema) directly in the same process produces the correct oneOf output, confirming the conversion itself works — the gate is the problem.
Suggested Fix
Allow non-object Zod schemas to reach toJsonSchemaCompat instead of falling back to EMPTY_OBJECT_JSON_SCHEMA:
inputSchema: (() => {
const obj = normalizeObjectSchema(tool.inputSchema);
if (obj) {
return toJsonSchemaCompat(obj, { strictUnions: true, pipeStrategy: 'input' });
}
// Pass any Zod schema through to toJsonSchemaCompat, not just z.object()
if (tool.inputSchema && (isZ4Schema(tool.inputSchema) || isZ3Schema(tool.inputSchema))) {
return toJsonSchemaCompat(tool.inputSchema, { strictUnions: true, pipeStrategy: 'input' });
}
return EMPTY_OBJECT_JSON_SCHEMA;
})()Environment
@modelcontextprotocol/sdk: 1.27.0zod: 4.3.6- Node.js 25.8.0