Repository navigation
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
Activity
mk-imagine commentedon Mar 8, 2026
Further investigation reveals the fix is more constrained than it first appears.
Patching
normalizeObjectSchema()to pass discriminated unions through totoJsonSchemaCompat()does generate the expectedoneOfJSON Schema — but the MCP protocol itself then rejects it. TheListToolsResultSchemavalidates thatinputSchema.type === "object", so any non-object schema (includingoneOf) causestools/listto throw:This means the discriminated union issue has two layers:
normalizeObjectSchema()silently drops unions → falls back to{}The correct fix is likely one of:
oneOfvariants' properties get merged with optional fields + an enum discriminator)z.object()insteadThe workaround (manual refactor to flat
z.object()with anz.enum()discriminator field) remains the only conformant approach until this is resolved at both layers.fix: upgrade MCP SDK to fix Zod 4 tool schema serialization
MayCXC commentedon Mar 12, 2026
It looks like on
main, the SDK has been refactored:normalizeObjectSchemaandtoJsonSchemaCompatare gone.mcp.tsnow callsschemaToJson()directly, which is a thin wrapper around Zod 4's nativez.toJSONSchema():This means the old silent-drop from
normalizeObjectSchemais gone onmain. Discriminated unions now pass through toz.toJSONSchema(). But the protocol conformance issue remains.z.toJSONSchema()on a discriminated union produces:{ "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { "type": "object", "properties": { "type": { "const": "page" }, ... }, "required": ["type", "search"] }, { "type": "object", "properties": { "type": { "const": "quiz" }, ... }, "required": ["type", "search"] } ] }No top-level
type: "object". TheTooltype inspec.types.tsrequires it:The
as Tool['inputSchema']cast hides this at compile time, but clients that validate responses will reject it.Since discriminated unions are always unions of object types (each variant must share a discriminator key),
type: "object"is semantically correct at the top level. The fix could be as simple as:For
z.object()schemas this is a no-op (they already havetype: "object"). For discriminated unions it adds the requiredtypefield alongsideoneOf. Valid JSON Schema, conformant with the MCP protocol, no information loss.fix: ensure tool inputSchema includes type: "object" for non-object Z…
fix(core): ensure standardSchemaToJsonSchema emits type:object
fix(server): preserve inputSchema for z.discriminatedUnion / z.union (m…
ankitvirdi4 commentedon Oct 4, 2026
for anyone still on 1.x: #1796 fixed this on
main(v2) only. thev1.xbackport is #2017, which passes unions throughnormalizeObjectSchemaand defaults a roottype: "object"sotools/liststays valid (addresses the protocol layer issue @mk-imagine raised above). until it merges you can try it vianpm i https://pkg.pr.new/@modelcontextprotocol/sdk@2017.