Frontend has been writing this pattern for a decade: the polymorphic component. You’ve probably written one. It’s the Button that is sometimes a link, with the two elements sharing one bag of props:
type ButtonProps = {
as?: "button" | "a";
onClick?: () => void;
href?: string;
disabled?: boolean;
};
Which means this compiles…
<Button as="a" href="/pricing" disabled />
and the disabled prop does nothing at all, because disabling isn’t a thing links do.
One fix people settled on is to let the as prop pick the contract, so that the bug stops compiling:
type ButtonProps =
| { as: "button"; onClick: () => void; disabled?: boolean }
| { as: "a"; href: string; target?: "_blank" };
<Button as="a" href="/pricing" />
<Button as="a" href="/pricing" disabled /> // type error
(There are a lot of reasons to hate the UX of a button/link, but let’s not diminish my argument by talking about it.)
A polymorphic MCP tool
Take a product that sends notifications. Let’s say it starts with email, so the MCP server gets one write tool: send_notification, a subject and a body. Then someone asks for SMS, followed by another request for Slack.
The obvious answer is a tool per channel:
send_email;
send_sms;
send_slack;
send_push;
send_whatsapp;
send_webhook;
Names that differ by one word, on a list that keeps getting longer, make it difficult for the model to call the right tool. There’s also the separate problem where some clients cap the number of MCP tools they’ll allow. Plus, every schema on it costs context on every request, whether called or not. All of which argues for one tool, and says nothing about what shape it should be.
The quick shape is one flat tool, every field legal on every channel:
send_notification = { via: "email" | "sms" | "slack", subject?, body?, reply_to?, channel?, thread? }
Which is the Button’s bug again. A subject on an SMS goes through and gets ignored, and the model learns the rules from whoever wrote the description, if they did.
The fix is the same as the Button’s fix: one field picks the contract, one branch per channel records the disagreement. This is the version you write and keep, and everything below gets generated from it. The field is named via for the dull reason that Slack already owns the word channel.
send_notification =
| { via: "email", subject, body_html, reply_to? }
| { via: "sms", body }
| { via: "slack", channel, text, thread? }
send_notification({ via: "sms", body: "Your table is ready" })
send_notification({ via: "sms", subject: "Hi", body: "Table ready" }) // rejected
As JSON Schema, the union is a oneOf with one branch per channel, each with its own required list and sealed with additionalProperties: false. A subject on an SMS fails it:
"inputSchema": {
"type": "object",
"oneOf": [
{
"properties": { "via": { "const": "email" }, "subject": { "type": "string" }, "body_html": { "type": "string" }, "reply_to": { "type": "string" } },
"required": ["via", "subject", "body_html"],
"additionalProperties": false
},
{
"properties": { "via": { "const": "sms" }, "body": { "type": "string", "maxLength": 160 } },
"required": ["via", "body"],
"additionalProperties": false
},
{
"properties": { "via": { "const": "slack" }, "channel": { "type": "string" }, "text": { "type": "string" }, "thread": { "type": "string" } },
"required": ["via", "channel", "text"],
"additionalProperties": false
}
]
}
Since July that’s also a legal inputSchema. SEP-2106 keeps type: "object" at the root and lets the oneOf sit beside it, which is the shape above, and it went into the spec at minor change ten, so maybe it’s not as useful as I think it could be. Whether to send it is a separate question from whether to write it. The union lives in your handler. By default what goes on the wire is flat, and generated from it:
function flatten(union) {
const properties: Record<string, unknown> = { via: { enum: union.oneOf.map((b) => b.properties.via.const) } };
for (const branch of union.oneOf) {
for (const [name, schema] of Object.entries(branch.properties)) {
if (name !== "via") properties[name] ??= schema;
}
}
return { type: "object", properties, required: ["via"], additionalProperties: false };
}
inputSchema = flatten(union);
// = { via: enum(email, sms, slack), subject?, body_html?, reply_to?, body?, channel?, text?, thread? }
description = describe(union);
// = "Send a notification. Email takes subject and body_html, and accepts
// reply_to. SMS takes body only, 160 characters max. Slack takes
// channel and text, and accepts thread."
That’s all of flatten. describe is the same walk over the branches writing a sentence each, about a dozen lines. And what flatten emits is the flat tool from a minute ago, field for field. What’s changed is where the rules live: in the union, once, with the description generated from it and the same union checking the call when it lands. The hand-written flat tool keeps its rules in the description, if anyone wrote them down, and that drifts. It says SMS is body-only, someone adds media_url to the handler, and now the model is arguing with an error that supposedly can’t happen.
What the union buys is enforcement you write once. Your server must validate every call regardless, the spec is firm on that, and the union is that validator. Read via first, then validate against that one branch (ajv calls this a discriminator, zod a discriminated union), and a bad call gets the rules back at the moment it fails:
{ code: "FIELD_NOT_ON_CHANNEL", path: "/subject",
message: "sms takes body only (160 chars). Drop subject, or set via to email." }
That error doesn’t fall out of a oneOf validator. Ask one to explain a failure and it says “must match exactly one schema”, which teaches the model nothing. Branch first and the error comes out channel-shaped.
The model learns one name and one workflow, and the fourth channel, when it comes, is a new branch in a schema the model already knows. The token saving is smaller than it looks, since the flat schema still carries every channel’s fields and the description a sentence per channel. What shrinks is the number of competing names, which even clients that lazy-load tools still have to tell apart.
Whether the model picks better from one tool than from six is the claim I can’t put a number on, and whether it shapes arguments better against a oneOf than against the flat schema is the other. The only numbers I know of point the flat way: flattening schemas has lifted function-calling scores before, which is part of why flat is the default here. One tool also moves the failure: picking the wrong tool becomes shaping the wrong arguments, caught a round trip later, and that’s the other reason the error has to carry the rule.
A client that understands the oneOf also rejects bad calls before they reach your handler, which is the case for sending it. That’s a bonus, not something to rely on, and today the list of clients that take it is short.
The clients aren’t ready (yet)
The official TypeScript SDK’s v1 took a z.discriminatedUnion() and reduced it to an empty schema, with no error or warning. The model received a tool that accepts anything. v2 emits the SEP-2106 shape (checked on 2.0.0), so mind which major you’re on. The rest:
- Azure AI Foundry accepts only a narrow schema subset.
anyOfandallOfare rejected outright, even nested under a property. - OpenAI’s strict mode supports
anyOfand no other combinator, and refuses even that at the root object. - Anthropic’s API rejects any combinator at the root, for every client, and accepts them nested. Claude Code copes with a root one by flattening it to one object and writing the field groups into the description before sending (v2.1.195 and later, older versions skip the tool). Anthropic started MCP, so that feels like a whiff?
None of this kills the design, because the union was never for the clients. It lives in your handler, and each client gets the shape it can parse, generated from the same union.
For strict mode that’s the union moved down a level, a nested anyOf inside one required payload field, every optional turned required-but-nullable and the 160-character cap gone to the description, since strict mode doesn’t carry maxLength:
"inputSchema": {
"type": "object",
"properties": {
"notification": {
"anyOf": [
{
"type": "object",
"properties": { "via": { "enum": ["email"] }, "subject": { "type": "string" }, "body_html": { "type": "string" }, "reply_to": { "type": ["string", "null"] } },
"required": ["via", "subject", "body_html", "reply_to"],
"additionalProperties": false
},
// sms and slack branches the same way
]
}
},
"required": ["notification"],
"additionalProperties": false
}
In return, strict mode refuses a subject on an SMS before you ever see it. Anthropic’s API takes the nested shape too, and it would be tempting as the default if the wrapper and the nulls were free and Azure took it.
Generating it yourself also means you choose the degradation, and it needn’t be the same for every client. MCP hands you the client’s name and version on every request (clientInfo, in _meta) and nothing about what it can parse, so the choice is a map from name to profile, with flat for any name you don’t recognise:
const profiles: Record<string, "flat" | "nested" | "oneOf"> = {
"ops-agent": "oneOf", // yours, and you tested it
"billing-bot": "nested", // talks to OpenAI in strict mode
};
const shape = profiles[clientInfo.name] ?? "flat";
Which is sniffing the client’s name, and the spec asks you not to, since clientInfo is self-reported and meant for logs. So a public endpoint sends flat to everyone, and the map is for a server that knows its callers, which is most internal ones. Either way the four clients above each invent their own version of your schema. Generate the versions yourself and they at least all come from the same union, which can even mean generating the tool-per-channel list from the top of this post, three thin wrappers over the one union, if a client turns out to route better that way.
If a client you care about starts taking the union at the root, that’s one line in the map. If none ever do (plausible), you’ve lost nothing. The transform isn’t new either, Claude Code does it on its side of the wire, and doing it on yours, from the union, keeps the branches as a description and a validator.
Where it stops being a good idea
Six months in, someone asks for in-app banners, and the request arrives sounding like a fourth branch: via: "banner", one more place a notification goes. It isn’t one. The other three are fire and gone, but a banner is state: it stays up until someone takes it down, which is why it needs a verb the others don’t. The fields would have lined up fine, and that’s what makes it the button that is secretly a link again, one layer down. The same test catches the quieter divergences: a branch that needs a different permission, can’t be retried like the others, or returns a different kind of result is a new tool wearing a familiar name. Banners get their own tools.
send_notification({ via: email | sms | slack, ... })
show_banner({ text, audience })
dismiss_banner({ banner_id })
Branch count is the quieter limit. The description grows a sentence per channel and the flat schema a handful of optional fields, and around the eighth you’ve rebuilt the long-list problem inside one tool. The verb test never fires, because the channels still mean the same thing, so bulk is its own reason to split. Past that you’re not consolidating any more, you’re building a search-and-execute surface, one tool to find the operation and one to run it, which is a different design and a different post.
There’s a floor too. A server with three tools needs none of this machinery. The union makes sense once the channels start stacking up.
Share the tool while the branches share the verb, the workflow and most of the payload. Split the moment via changes what the verb means, or what it costs to get wrong.