Skip to content

fix(server): preserve inputSchema for z.discriminatedUnion / z.union#2017

Open
ankitvirdi4 wants to merge 1 commit intomodelcontextprotocol:v1.xfrom
ankitvirdi4:fix/1643-discriminated-union-input-schema
Open

fix(server): preserve inputSchema for z.discriminatedUnion / z.union#2017
ankitvirdi4 wants to merge 1 commit intomodelcontextprotocol:v1.xfrom
ankitvirdi4:fix/1643-discriminated-union-input-schema

Conversation

@ankitvirdi4
Copy link
Copy Markdown

Summary

Fixes #1643. registerTool / registerPrompt silently advertised an empty schema in tools/list when given a z.discriminatedUnion(...) or a z.union([z.object(...), ...]). Tool calls still validated correctly via the fallback in validateToolInput, which masked the bug from end-to-end tests.

Repro on v1.x HEAD

import * as z from 'zod/v4';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

const server = new McpServer({ name: 't', version: '1' });
server.registerTool('mutate', {
  inputSchema: z.discriminatedUnion('action', [
    z.object({ action: z.literal('create'), name: z.string() }),
    z.object({ action: z.literal('delete'), id: z.string() }),
  ]),
}, async args => ({ content: [{ type: 'text', text: JSON.stringify(args) }] }));

// tools/list returns: inputSchema: { type: 'object', properties: {} }
// branches dropped on the wire.

Root cause

normalizeObjectSchema() (in src/server/zod-compat.ts) returned undefined for any schema whose root was not z.object(...). The tools/list handler then substituted EMPTY_OBJECT_JSON_SCHEMA. The downstream toJsonSchemaCompat already handles unions correctly (zod-to-json-schema for v3, Mini's z.toJSONSchema for v4) but never got called.

Fix

Two surgical changes:

  1. src/server/zod-compat.tsnormalizeObjectSchema() now also passes through:

    • v4 schemas with _zod.def.type === 'union' (covers both z.union and z.discriminatedUnion)
    • v3 schemas with _def.typeName of ZodDiscriminatedUnion or ZodUnion

    Schemas whose root is genuinely non-object (z.string(), z.array(...)) still return undefined, matching prior behavior.

  2. src/server/mcp.ts — wraps the conversion result with a small ensureObjectRoot() helper that defaults type: "object" when the converter omits it. Discriminated unions and unions emit oneOf / anyOf without a root type, but the MCP spec requires tool input/output schemas to describe objects, so we default it on the way out. Plain z.object(...) already sets type: "object", so this is a no-op for the common case. Schemas with an explicit non-object root type are left alone (they fall out as user errors downstream, which is the right signal).

Tests

Three regression tests under "Tools with union and intersection schemas". The file uses describe.each(zodTestMatrix) so each runs once for v3 and once for v4 — six actual cases:

  • discriminated-union inputSchema is exposed in tools/list with type: "object" + oneOf / anyOf
  • regular union of objects, same
  • discriminated-union outputSchema, same, plus end-to-end callTool round trip

Includes a changeset entry.

Test plan

  • npx vitest run test/server/mcp.test.ts — 238 / 238 passing
  • npx vitest run (full suite) — 1585 / 1585 passing
  • npm run typecheck clean
  • npm run lint clean
  • Manual end-to-end with the SDK's in-memory transport: tools/list returns the proper schema with branches; valid input round-trips; malformed input returns proper validation error

…odelcontextprotocol#1643)

normalizeObjectSchema returned undefined for any schema whose root was not
z.object(...), so registerTool silently advertised an empty schema in
tools/list when given z.discriminatedUnion(...). Tool calls still
validated correctly via the fallback in validateToolInput, which masked
the bug from end-to-end tests.

Pass discriminated unions and unions through unchanged in
normalizeObjectSchema; let toJsonSchemaCompat handle the actual
conversion (zod-to-json-schema for v3, Mini's z.toJSONSchema for v4).
Both emit oneOf/anyOf branches.

Default a top-level type: "object" on the emitted JSON Schema in mcp.ts
so the wire payload is spec compliant when the converter does not include
one (the discriminated-union and union case).

Adds three regression tests under "Tools with union and intersection
schemas" exercising tools/list, callTool, and outputSchema for v3 and v4.
@ankitvirdi4 ankitvirdi4 requested a review from a team as a code owner May 5, 2026 01:18
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: b0e4b04

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2017

commit: b0e4b04

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant