Guide · TypeScript Advanced Patterns
MCP server discriminated unions
An MCP server with many fine-grained tools gives the LLM a precise schema for each action. But some domains have operations that share most of their structure — a file system tool that creates, reads, updates, and deletes — and splitting each variant into its own tool multiplies the tool list without giving the LLM useful extra information. Discriminated unions let you model these multi-action tools with a single TypeScript type that narrows exhaustively: every branch is checked at compile time, no variant is forgotten, and the schema the LLM sees is a clear oneOf with a mandatory action discriminator. This guide covers how to apply discriminated unions to MCP tool inputs, tool results, and internal event types.
TL;DR
Use z.discriminatedUnion('action', [...]) for tool input schemas where the valid fields depend on which action is requested. The Zod type system narrows automatically inside a switch (args.action) block — TypeScript knows which fields are present in each branch and raises a compile error if you add a new variant without handling it. For tool outputs, model success and error variants as a discriminated union so consumers know at compile time which fields to expect without checking for undefined.
When to use discriminated unions vs separate tools
The MCP spec recommends one tool per operation when operations have meaningfully different schemas. Discriminated unions are the right choice when:
- Operations share a majority of their fields (create vs update — both need a name, description, and owner)
- The LLM needs to choose between variants at call time based on context (CRUD actions on a resource type)
- Tool list length matters (a server with 50+ tools degrades LLM performance; grouping related operations reduces count)
- You want exhaustive handling guaranteed by the compiler — forgetting a case is a type error, not a runtime bug
Separate tools are better when operations have completely different schemas, different required permissions, or different rate limit tiers — mixing them into one discriminated union makes the schema harder for the LLM to follow.
z.discriminatedUnion() for tool input schemas
Zod's z.discriminatedUnion() generates a oneOf JSON Schema with the discriminator field marked required, which gives the LLM a clear signal that it must specify the action type before filling other fields:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const NoteActionSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('create'),
title: z.string().min(1).max(256),
content: z.string().max(50_000),
tags: z.array(z.string()).max(20).default([]),
}),
z.object({
action: z.literal('update'),
note_id: z.string().uuid(),
title: z.string().min(1).max(256).optional(),
content: z.string().max(50_000).optional(),
tags: z.array(z.string()).max(20).optional(),
}),
z.object({
action: z.literal('delete'),
note_id: z.string().uuid(),
confirm: z.literal(true).describe('Must be true to confirm destructive deletion'),
}),
z.object({
action: z.literal('search'),
query: z.string().min(1).max(500),
tags: z.array(z.string()).optional(),
limit: z.number().int().min(1).max(50).default(10),
}),
]);
const server = new McpServer({ name: 'notes-mcp', version: '1.0.0' });
server.tool(
'notes',
'Create, update, delete, or search notes. Specify action: create | update | delete | search.',
NoteActionSchema,
async (args) => {
switch (args.action) {
case 'create': {
const note = await db.notes.create(args);
return { content: [{ type: 'text', text: `Note created: ${note.id}` }] };
}
case 'update': {
const note = await db.notes.update(args.note_id, args);
if (!note) return { isError: true, content: [{ type: 'text', text: 'Note not found' }] };
return { content: [{ type: 'text', text: `Note ${note.id} updated` }] };
}
case 'delete': {
await db.notes.delete(args.note_id);
return { content: [{ type: 'text', text: `Note ${args.note_id} deleted` }] };
}
case 'search': {
const results = await db.notes.search(args.query, args);
return { content: [{ type: 'text', text: results.map(n => `${n.id}: ${n.title}`).join('\n') }] };
}
// TypeScript raises an error here if a new case is added to the union without a branch:
// default: const _: never = args; throw new Error(`Unhandled action: ${(args as any).action}`);
}
}
);
Inside each case block, TypeScript narrows args to the exact variant type. Accessing args.note_id inside case 'create' is a compile error — note_id doesn't exist on the create variant.
Exhaustive switch with never check
Add a never default case to make the compiler report missing branches when you extend the union:
function assertNever(x: never, label: string): never {
throw new Error(`Unhandled ${label}: ${JSON.stringify(x)}`);
}
// In the switch:
switch (args.action) {
case 'create': return handleCreate(args);
case 'update': return handleUpdate(args);
case 'delete': return handleDelete(args);
case 'search': return handleSearch(args);
default: return assertNever(args, 'NoteAction');
// ^^^^^ TypeScript error if any variant is missing a case branch
}
When you add a fifth action — z.object({ action: z.literal('export'), ... }) — to the union, assertNever(args, 'NoteAction') immediately becomes a type error: the args type in the default branch is no longer never (it's the new export variant). This is the compile-time guarantee that no variant is silently skipped.
Discriminated unions for tool output types
MCP tool results are untyped at the protocol level — the content array carries arbitrary text. Internally, modeling your handler return types as a discriminated union prevents callers from forgetting to check whether a result is an error:
// Internal result type — not the MCP protocol type, but your domain type
type ToolResult<T> =
| { ok: true; value: T }
| { ok: false; error: string; code: 'NOT_FOUND' | 'FORBIDDEN' | 'VALIDATION' | 'INTERNAL' };
// Service function returning a discriminated union result
async function getNote(noteId: string, userId: string): Promise<ToolResult<Note>> {
const note = await db.notes.findById(noteId);
if (!note) return { ok: false, error: `Note ${noteId} not found`, code: 'NOT_FOUND' };
if (note.owner !== userId) return { ok: false, error: 'Access denied', code: 'FORBIDDEN' };
return { ok: true, value: note };
}
// Tool handler — exhaustive by construction
server.tool('get_note', 'Get a note by ID', {
note_id: z.string().uuid(),
user_id: z.string().uuid(),
}, async ({ note_id, user_id }) => {
const result = await getNote(note_id, user_id);
if (!result.ok) {
// result.error and result.code are narrowed — no optional chaining needed
return { isError: true, content: [{ type: 'text', text: `${result.code}: ${result.error}` }] };
}
// result.value is narrowed to Note — no undefined check needed
const note = result.value;
return { content: [{ type: 'text', text: `${note.title}\n\n${note.content}` }] };
});
Discriminated unions for multi-resource MCP servers
Servers that manage multiple resource types — projects, tasks, users — sometimes model operations across resource types in a single tool. A discriminated union on resource_type produces a schema the LLM can follow and a handler the compiler can verify:
const ResourceQuerySchema = z.discriminatedUnion('resource_type', [
z.object({
resource_type: z.literal('project'),
project_id: z.string().uuid(),
include_tasks: z.boolean().default(false),
}),
z.object({
resource_type: z.literal('task'),
task_id: z.string().uuid(),
include_comments: z.boolean().default(false),
}),
z.object({
resource_type: z.literal('user'),
user_id: z.string().uuid(),
include_activity: z.boolean().default(false),
}),
]);
server.tool('get_resource', 'Fetch a project, task, or user by ID', ResourceQuerySchema, async (args) => {
switch (args.resource_type) {
case 'project': {
const project = await db.projects.findById(args.project_id); // project_id narrowed
const tasks = args.include_tasks ? await db.tasks.findByProject(args.project_id) : [];
return formatProject(project, tasks);
}
case 'task': {
const task = await db.tasks.findById(args.task_id); // task_id narrowed
return formatTask(task, args.include_comments);
}
case 'user': {
const user = await db.users.findById(args.user_id); // user_id narrowed
return formatUser(user, args.include_activity);
}
default: return assertNever(args, 'ResourceType');
}
});
The narrowing means the compiler catches cross-variant field access (args.task_id inside the project case) and the assertNever default catches missing cases when new resource types are added.
Integration with MCP tool annotations
Combine discriminated unions with tool annotations to mark destructive variants clearly. The destructiveHint annotation should be on the tool, not per-variant, so use it when any variant is destructive:
server.tool(
'notes',
{
title: 'Note management — create, update, delete, search',
readOnlyHint: false,
destructiveHint: true, // delete variant is destructive
idempotentHint: false,
},
NoteActionSchema,
async (args) => { /* ... */ }
);
Monitoring polymorphic tools
Discriminated union tools have more failure surfaces than single-action tools: a regression in one variant's handler makes that action return isError: true while other variants remain healthy. Transport-level health checks won't detect this — initialize succeeds and tools/list returns the tool.
AliveMCP monitors your MCP endpoints at the protocol level, probing actual tool calls to detect handler failures invisible to HTTP health checks. Pair protocol monitoring with per-variant error rate tracking in your structured logs to pinpoint which action in a discriminated union tool is failing.
Further reading
- MCP server branded types — nominal typing for tool parameters
- MCP server generics — reusable CRUD and pagination patterns
- MCP server Zod validation — schema-first tool definitions
- MCP server tool design — naming, granularity, and parameter patterns
- MCP server tool annotations — readOnlyHint, destructiveHint, idempotentHint
- MCP server error handling — isError responses and LLM recovery hints
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers