Skip to content

registerTool() silently drops inputSchema for z.discriminatedUnion() — normalizeObjectSchema only passes z.object() #1643

Description

@mk-imagine

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_SCHEMA

For 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.0
  • zod: 4.3.6
  • Node.js 25.8.0

Activity

  1. mk-imagine commented on Mar 8, 2026

    @mk-imagine
    Author

    Further investigation reveals the fix is more constrained than it first appears.

    Patching normalizeObjectSchema() to pass discriminated unions through to toJsonSchemaCompat() does generate the expected oneOf JSON Schema — but the MCP protocol itself then rejects it. The ListToolsResultSchema validates that inputSchema.type === "object", so any non-object schema (including oneOf) causes tools/list to throw:

    ZodError: Invalid input: expected "object" at path tools[0].inputSchema.type
    

    This means the discriminated union issue has two layers:

    1. SDK layer: normalizeObjectSchema() silently drops unions → falls back to {}
    2. Protocol layer: even if the SDK were fixed to pass unions through, the MCP spec's own response schema forbids non-object inputSchemas

    The correct fix is likely one of:

    • Auto-flatten discriminated unions into a merged object schema at the SDK level (the oneOf variants' properties get merged with optional fields + an enum discriminator)
    • Raise a clear error when a discriminated union is passed, directing developers to use a flat z.object() instead

    The workaround (manual refactor to flat z.object() with an z.enum() discriminator field) remains the only conformant approach until this is resolved at both layers.

  2. MayCXC commented on Mar 12, 2026

    @MayCXC

    It looks like on main, the SDK has been refactored: normalizeObjectSchema and toJsonSchemaCompat are gone. mcp.ts now calls schemaToJson() directly, which is a thin wrapper around Zod 4's native z.toJSONSchema():

    // packages/server/src/server/mcp.ts (main branch)
    inputSchema: tool.inputSchema
        ? (schemaToJson(tool.inputSchema, { io: 'input' }) as Tool['inputSchema'])
        : EMPTY_OBJECT_JSON_SCHEMA,
    // packages/core/src/util/schema.ts (main branch)
    export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
        return z.toJSONSchema(schema, options) as Record<string, unknown>;
    }

    This means the old silent-drop from normalizeObjectSchema is gone on main. Discriminated unions now pass through to z.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". The Tool type in spec.types.ts requires it:

    inputSchema: {
        type: 'object';
        properties?: { [key: string]: JSONValue };
        required?: string[];
    };

    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:

    inputSchema: tool.inputSchema
        ? { type: 'object' as const, ...schemaToJson(tool.inputSchema, { io: 'input' }) } as Tool['inputSchema']
        : EMPTY_OBJECT_JSON_SCHEMA,

    For z.object() schemas this is a no-op (they already have type: "object"). For discriminated unions it adds the required type field alongside oneOf. Valid JSON Schema, conformant with the MCP protocol, no information loss.

  3. added a commit that references this issue on Mar 12, 2026
    2cfd6f7
  4. added a commit that references this issue on Mar 27, 2026
    65fc7fa
  5. ankitvirdi4 commented on Oct 4, 2026

    @ankitvirdi4

    for anyone still on 1.x: #1796 fixed this on main (v2) only. the v1.x backport is #2017, which passes unions through normalizeObjectSchema and defaults a root type: "object" so tools/list stays valid (addresses the protocol layer issue @mk-imagine raised above). until it merges you can try it via npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2017.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions