Skip to content

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

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

ankitvirdi4 wants to merge 1 commit into
modelcontextprotocol: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.ts — normalizeObjectSchema() 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

@ankitvirdi4
ankitvirdi4 requested a review from a team as a code owner May 5, 2026 01:18
@changeset-bot

changeset-bot Bot commented May 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7b808a9

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

pkg-pr-new Bot commented May 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 7b808a9

@ankitvirdi4

Copy link
Copy Markdown
Author

gentle bump on this one. happy to rebase against v1.x if its drifted, or rescope to drop the ensureObjectRoot helper if youd prefer the narrower change (just the normalizeObjectSchema pass-through). let me know if anything needs revising and ill turn it around quickly.

@joselvelez

Copy link
Copy Markdown

any update on when/if this will get merged?

…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 force-pushed the fix/1643-discriminated-union-input-schema branch from 0ce5cf5 to 7b808a9 Compare October 4, 2026 11:22
@ankitvirdi4

ankitvirdi4 commented Oct 4, 2026 •

Copy link
Copy Markdown
Author

@felixweinberger quick context since #1643 shows as closed: it was closed by #1796, which fixed this on main (v2, packages/core/src/util/standardSchema.ts). That fix never landed on v1.x. On current v1.x, normalizeObjectSchema still returns undefined for z.discriminatedUnion(...) / z.union(...), so tools/list advertises an empty inputSchema for those tools.

This PR is the v1 backport. It does the same two things #1796 does on main:

  1. lets unions pass through normalizeObjectSchema so the schema actually gets converted
  2. defaults a root type: "object" when missing, so the emitted schema stays spec compliant

re my earlier offer to drop the ensureObjectRoot helper: i'd now recommend keeping it. without it, tools/list throws expected "object" at path tools[0].inputSchema.type because ListToolsResultSchema requires a root object type (details from @mk-imagine on #1643). so the pass-through alone would swap a silent bug for a hard error.

Just rebased onto latest v1.x (single commit, no conflicts). lint, build, and the full suite (1858 tests) pass locally. would appreciate a look when you get a chance.

@joselvelez thanks for the nudge. it's rebased and ready on my side, now waiting on maintainer review. in the meantime you can try it with npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2017.

@soorajlegend

Copy link
Copy Markdown

Confirming this in production on 1.29.0: a z.discriminatedUnion inputSchema passed to registerTool was advertised in tools/list as { "type": "object", "properties": {} }, and agents treated the tool as taking no arguments. No error or warning anywhere.

What we do while waiting for this: action is a z.enum, payload is z.record(z.unknown()), the handler validates each action's payload with its own schema, and the per-action fields are listed in the tool description.

One trap for anyone switching to z.union instead: Zod returns the first branch that parses, and object branches strip unknown keys by default. Our move payload also matched the update branch, so status was silently dropped. If this PR makes z.union a supported inputSchema, a docs note that branch order (or .strict() branches) matters might save people some time.

Thanks for working on the v1.x backport.

This branch has not been deployed

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

3 participants