TypeScript guide · 2026-06-13 · Advanced MCP server patterns
Advanced TypeScript Patterns for MCP Servers: Branded Types, Generics, and Type-Safe Plugin Systems
Every TypeScript MCP server starts with the basics: Zod schemas for tool inputs, async handlers, isError: true on failure. That gets you to a working server. Getting to a maintainable server — one where adding the fifteenth entity type doesn't copy-paste five tools, where a new action variant can't accidentally skip its handler, where passing a ProjectId where a UserId is expected is a compile error — requires a second tier of TypeScript. This guide synthesizes five patterns from the branded types, discriminated unions, conditional types, declaration merging, and generics deep-dives into a single decision framework: which pattern eliminates which bug class, when to reach for each, and where compile-time guarantees end and runtime monitoring begins.
The five patterns, mapped to the bugs they eliminate
Each pattern addresses a distinct failure mode. Before choosing, identify the bug class you're actually trying to prevent:
| Pattern | Bug class eliminated | Example bug prevented |
|---|---|---|
| Branded types | Wrong-ID-in-right-slot | addMember(userId, projectId) args transposed — compiles, corrupts data |
| Discriminated unions | Missing-branch | New action: 'export' variant added, handler switch has no case 'export' — silent runtime error |
| Conditional types | Handler-schema mismatch | Handler arg type annotation drifts from Zod schema — TypeScript thinks user_id is string, schema added .uuid() refinement the handler doesn't know about |
| Declaration merging | Missing-plugin-implementation | Plugin registered in the tool registry but context augmentation never written — handler accesses ctx.auth that doesn't exist at runtime |
| Generics | Copy-paste divergence | 5 near-identical CRUD tool sets for different entities — one gets a bug fix, four don't; one gets rate limiting, four don't |
These aren't hypothetical. Each bug class appears predictably as an MCP server grows past about 20 tools. The patterns below become worth their learning curve around that inflection point.
Pattern 1: Branded types — ending wrong-ID bugs forever
TypeScript's structural type system means a UserId typed as string is completely interchangeable with a ProjectId typed as string. The compiler won't catch this:
// addMember(projectId: string, userId: string)
await db.memberships.create(args.user_id, args.project_id); // args swapped — no error
Branded types add a phantom type tag that makes these types nominally distinct even though they're the same primitive at runtime:
declare const brand: unique symbol;
type Brand<T, TBrand extends string> = T & { readonly [brand]: TBrand };
export type UserId = Brand<string, 'UserId'>;
export type ProjectId = Brand<string, 'ProjectId'>;
// Now the compiler catches the transposition:
// addMember(userId, projectId) // correct
// addMember(projectId, userId) // Error: Argument of type 'ProjectId' is not
// // assignable to parameter of type 'UserId'
The Zod integration makes this practical — z.string().regex(...).brand<'UserId'>() validates the format and brands the type in one expression, so tool schemas double as constructors:
export const UserIdSchema = z
.string()
.regex(/^usr_[0-9a-z]{24}$/, 'Expected usr_<24-char ID>')
.brand<'UserId'>()
.describe('A user identifier. Obtain from list_users or get_current_user.');
server.tool('add_user_to_project', '...', {
user_id: UserIdSchema, // handler receives UserId, not string
project_id: ProjectIdSchema, // handler receives ProjectId, not string
}, async ({ user_id, project_id }) => {
await db.memberships.create(user_id, project_id); // can't be swapped
});
The .describe() call also tells the LLM which tool to call to obtain a valid ID — preventing it from constructing IDs from memory or hallucinating values that will format-validate but hit deleted records.
When to reach for this pattern: any server where more than two tools share an ID type. One-tool servers don't need it. Five-tool servers already benefit.
Pattern 2: Discriminated unions — exhaustive variant handling
Some MCP tool domains have operations that share most of their structure — note management (create, update, delete, search), file operations (read, write, delete, list). Splitting each variant into its own tool balloons the tool list; combining them naively into a single schema with many optional fields creates ambiguity for the LLM and a handler with no compiler-enforced exhaustiveness.
Discriminated unions solve both problems. z.discriminatedUnion('action', [...]) generates a clean oneOf JSON Schema with a required action discriminator, and TypeScript narrows the type inside each case branch of the handler switch:
const NoteActionSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('create'), title: z.string(), content: z.string() }),
z.object({ action: z.literal('update'), note_id: z.string().uuid(), content: z.string().optional() }),
z.object({ action: z.literal('delete'), note_id: z.string().uuid(), confirm: z.literal(true) }),
z.object({ action: z.literal('search'), query: z.string(), limit: z.number().default(10) }),
]);
server.tool('notes', 'Manage notes', NoteActionSchema, async (args) => {
switch (args.action) {
case 'create': /* args.title and args.content are narrowed */ return handleCreate(args);
case 'update': /* args.note_id is narrowed; args.title is never */ return handleUpdate(args);
case 'delete': return handleDelete(args);
case 'search': return handleSearch(args);
default: return assertNever(args, 'NoteAction'); // compile error if any case is missing
}
});
The assertNever pattern is the key. When you add a fifth variant — z.object({ action: z.literal('export'), ... }) — the default branch immediately becomes a type error: args is no longer never, it's the new export variant. The compiler forces you to handle it before the code builds.
When to reach for this pattern: operations that share a resource type, have 2–6 variants, and where you want the compiler to enforce exhaustiveness as the union grows over time.
Pattern 3: Conditional types — the handler always matches the schema
A subtle drift bug: you define a Zod schema, annotate the handler arguments manually, the schema changes, the annotation doesn't. TypeScript can't catch the mismatch because you told it what the type is rather than asking it to infer from the schema.
// Schema and annotation start in sync:
const GetUserSchema = { user_id: z.string().uuid() };
server.tool('get_user', '...', GetUserSchema, async (args: { user_id: string }) => { ... });
// Schema tightened later — annotation not updated:
const GetUserSchema = { user_id: UserIdSchema }; // branded now
// args.user_id is still typed as 'string' in the handler — the brand is lost
Conditional types — specifically z.infer<T> — eliminate this drift entirely. The handler argument type is always derived from the schema, never declared independently:
// Core utility: map a Zod raw shape to its inferred type
type ToolHandler<TSchema extends z.ZodRawShape> =
(args: z.infer<z.ZodObject<TSchema>>) => Promise<McpToolResult>;
// Helper: registers a tool with the handler type inferred from the schema
function registerTool<TSchema extends z.ZodRawShape>(
server: McpServer,
name: string,
description: string,
schema: TSchema,
handler: ToolHandler<TSchema>
): void {
server.tool(name, description, schema, handler);
}
// Now the handler type is always derived — it can't drift:
registerTool(server, 'get_user', 'Get a user by ID', {
user_id: UserIdSchema, // branded
}, async (args) => {
// args.user_id is UserId — inferred, not annotated
const user = await db.users.findById(args.user_id);
return { content: [{ type: 'text', text: user?.name ?? 'Not found' }] };
});
The deeper power is in conditional type utilities that propagate type information through the entire tool lifecycle — paginated result shapes derived from entity types, middleware signatures that preserve handler types through the composition chain, feature flag types that only activate when their defining module is imported.
When to reach for this pattern: any time you find yourself manually annotating a handler argument type that could be inferred from the Zod schema. Manual annotations are the source of drift bugs; inference prevents them.
Pattern 4: Declaration merging — plugin systems without a god interface
MCP servers that grow beyond a single team's tools develop a coordination problem: every plugin needs to add properties to the shared request context (auth info, rate limit state, tenant ID), but a single central McpServerContext interface becomes a merge-conflict bottleneck as more plugins contribute to it.
Declaration merging — TypeScript's ability to combine multiple declarations of the same interface — solves this without any central coordination:
// core/context.ts — the extensible base
export interface McpServerContext {
requestId: string;
logger: Logger;
}
// auth/plugin.ts — no import of a "central" file needed
declare module '../core/context' {
interface McpServerContext {
auth: { userId: string; orgId: string; scopes: string[]; isAdmin: boolean };
}
}
// rate-limit/plugin.ts — independently adds its own properties
declare module '../core/context' {
interface McpServerContext {
rateLimit: { remaining: number; resetAt: number; tier: 'free' | 'pro' | 'enterprise' };
}
}
// Any handler — sees the fully merged context with no extra imports:
async function handleListProjects(args: { org_id: string }, ctx: McpServerContext) {
if (ctx.rateLimit.remaining === 0) return rateLimitError(ctx.rateLimit.resetAt);
const projects = await db.projects.findByOrg(ctx.auth.orgId);
ctx.logger.info({ event: 'list_projects', count: projects.length });
return formatProjects(projects);
}
The compile-time guarantee: a handler that accesses ctx.auth.userId will fail to build unless the auth plugin module has been imported (and its declaration merged). You can't use a plugin's context contributions without loading the plugin. The missing-plugin-impl bug becomes a build error, not a runtime TypeError: Cannot read properties of undefined.
The same mechanism works for typed tool registries via namespace merging — each plugin contributes its tool names and argument types to a central ToolRegistry.Tools interface, and a dispatch function that routes tool calls by name can be type-safe across all plugins without a hand-maintained list.
When to reach for this pattern: servers with more than two independently developed plugins, or any codebase where a central "here's everything" interface is causing merge conflicts.
Pattern 5: Generics — one factory instead of five tool sets
The copy-paste divergence bug is the most common quality problem in maturing MCP servers. The pattern is predictable: user tools ship first, project tools are copied from user tools with find-replace, task tools are copied from project tools. Then a bug is found in the not-found error message format. It gets fixed in user tools. Project tools and task tools keep the old format. Six months later, all three diverge enough that a refactor requires reading all of them.
Generics prevent this by expressing the shared structure once and filling in entity-specific details as parameters:
interface Repository<T, TCreate, TUpdate> {
findById(id: string): Promise<T | null>;
findMany(opts: PaginationOpts): Promise<{ items: T[]; total: number }>;
create(data: TCreate): Promise<T>;
update(id: string, data: TUpdate): Promise<T | null>;
delete(id: string): Promise<boolean>;
}
function createCrudTools<T, TCreate, TUpdate>(
server: McpServer,
opts: CrudFactoryOptions<T, TCreate, TUpdate>
): void {
// Registers get_{entity}, list_{entityPlural}, create_{entity},
// update_{entity}, delete_{entity} — five tools from one function
}
// Users: one call, five tools
createCrudTools(server, {
entity: 'user', entityPlural: 'users',
createSchema: CreateUserSchema, updateSchema: UpdateUserSchema,
repo: userRepo,
formatOne: (u) => `${u.id} | ${u.name} | ${u.email}`,
formatMany: (u) => `${u.id} ${u.name}`,
});
// Projects: one call, five more tools — from the same factory
createCrudTools(server, {
entity: 'project', entityPlural: 'projects',
createSchema: CreateProjectSchema, updateSchema: UpdateProjectSchema,
repo: projectRepo,
formatOne: (p) => `${p.id} | ${p.name} | owner: ${p.ownerId}`,
formatMany: (p) => `${p.id} ${p.name}`,
});
Now a bug fix in createCrudTools propagates to every entity. Adding rate limiting to the factory means every entity gets it. The divergence can't happen because there's only one source.
Generics also power the Result<T, E> container pattern — a typed discriminated union that makes service layer error paths explicit without try/catch, and maps cleanly to MCP's isError: true protocol:
type Result<T, E = string> = { _tag: 'ok'; value: T } | { _tag: 'err'; error: E };
function toMcpResult<T>(result: Result<T>, format: (v: T) => string): McpToolResult {
return result._tag === 'err'
? { isError: true, content: [{ type: 'text', text: result.error }] }
: { content: [{ type: 'text', text: format(result.value) }] };
}
When to reach for this pattern: the moment you write your second near-identical tool set for a different entity. That's the earliest viable extraction point — not too early (premature abstraction), not too late (divergence already started).
The unified insight: compile-time coverage ends at process boundaries
Each pattern above eliminates a real class of bugs. Taken together, they cover the five main ways TypeScript MCP server code fails because of code: wrong types passed between tools, missing branches in handlers, handler-schema drift, missing plugin implementations, and copy-paste divergence in repeated structure.
What none of them cover is the other class of production failures — failures that don't come from your code at all:
- A database connection pool exhausted at 3 AM because a query regressed to a full-table scan
- An upstream API returning 503 that your tool passes through as
isError: truewhileinitializeandtools/listremain healthy - A valid
UserIdthat passesUserIdSchema.parse()but references a user deleted by a background job three minutes ago - A Docker container that's still responding to HTTP health checks but has a corrupted in-memory cache that makes specific tool calls return wrong results
The type system can verify that your code is internally consistent. It cannot verify that the external world — databases, APIs, network paths, memory state — remains consistent with your code's assumptions. TypeScript's type checking happens at build time; these failures happen at runtime, hours or days after the build passed.
This isn't a criticism of advanced TypeScript — the patterns above are genuinely worth using. It's a framing: the two kinds of failures require two different tools. TypeScript catches structural code bugs. External monitoring catches runtime environment failures. A perfectly type-safe MCP server can still be silently broken in ways that only reveal themselves when an LLM tries to call a specific tool with live data.
Monitoring what TypeScript can't see
The failure mode that concerns MCP server operators most is the invisible kind: a server that appears healthy at the transport layer — responds to pings, returns from initialize, lists tools successfully — but returns isError: true on actual tool calls because a dependency is broken. The LLM that's invoking these tools gets a stream of error responses, silently degrades its behavior, and may never surface the root cause to the developer.
AliveMCP monitors your MCP endpoint every 60 seconds using the full protocol handshake — not just an HTTP ping. It calls actual tools with valid inputs (including inputs that exercise branded-type validation, multi-action discriminated union tools, and factory-generated CRUD tools) and checks for isError: true results that signal handler-level failures. When a database dependency goes down and all your factory-generated get_*, list_*, and create_* tools start failing simultaneously, protocol-level monitoring catches it in under 60 seconds and alerts before your users report it.
The layered strategy: advanced TypeScript patterns for compile-time structural correctness + protocol-level external monitoring for runtime environment failures. Both layers are necessary; neither is sufficient alone.
Combining the patterns
These patterns compose naturally. A production MCP server that uses all five looks like:
// 1. Branded ID types prevent wrong-ID bugs across all tools
export const UserIdSchema = z.string().regex(/^usr_[0-9a-z]{24}$/).brand<'UserId'>();
export const ProjectIdSchema = z.string().regex(/^prj_[0-9a-z]{24}$/).brand<'ProjectId'>();
// 2. Generic CRUD factory registers 5 tools per entity without copy-paste
createCrudTools(server, {
entity: 'user', entityPlural: 'users',
createSchema: CreateUserSchema, updateSchema: UpdateUserSchema,
repo: userRepo,
formatOne: formatUser,
formatMany: formatUserLine,
filterSchema: { org_id: OrgIdSchema.optional() }, // branded filter ID
});
// 3. Discriminated union for multi-action operations (not covered by CRUD)
const NoteActionSchema = z.discriminatedUnion('action', [/* create, update, delete, search */]);
server.tool('notes', '...', NoteActionSchema, async (args) => {
switch (args.action) {
case 'create': /* ... */
case 'search': /* ... */
default: return assertNever(args, 'NoteAction'); // exhaustive
}
});
// 4. Conditional types keep handler types in sync with schemas
registerTool(server, 'transfer_ownership', '...', {
project_id: ProjectIdSchema,
new_owner_id: UserIdSchema,
}, async (args) => { // args typed from schema, never drifts
const result = await transferOwnership(args.project_id, args.new_owner_id);
return toMcpResult(result, formatProject); // generic Result container
});
// 5. Declaration merging adds auth + rate-limit context without central god interface
// (in auth/plugin.ts):
declare module '../core/context' {
interface McpServerContext {
auth: { userId: UserId; orgId: OrgId; scopes: string[] };
}
}
Each pattern solves a different problem independently. Together they make the codebase both structurally correct (TypeScript catches code bugs) and observable (monitoring catches runtime failures). A mature MCP server uses both layers.
Quick reference: which pattern to use when
| Situation | Pattern |
|---|---|
| Multiple tools accept the same primitive type (IDs, slugs, tokens) | Branded types |
| One tool handles multiple operation variants on the same resource | Discriminated unions |
| Handler arg type might drift from the Zod schema definition | Conditional types (z.infer) |
| Multiple plugins contribute context properties, middleware, or tools | Declaration merging |
| Same CRUD pattern repeats across 2+ entity types | Generics (CRUD factory) |
| Service functions return multiple failure modes the handler must handle | Generic Result<T, E> container |
| Runtime failures — broken DB, unreachable APIs, invisible handler errors | External protocol monitoring (AliveMCP) |
Further reading
- MCP server branded types — nominal typing for tool parameters — the full branded types reference with Zod integration, cross-tool ID safety, and non-ID use cases
- MCP server discriminated unions — polymorphic tool inputs and outputs — multi-action tools, exhaustive switch, typed result variants
- MCP server conditional types — dynamic tool registration and type-safe factories —
z.infer-based handler types, middleware composition, compile-time invariants - MCP server declaration merging — plugin systems and module augmentation — context augmentation, tool registry namespace merging, plugin contract enforcement
- MCP server generics — reusable tool builders and CRUD patterns —
createCrudToolsfactory, genericResultcontainer, constrained generics - MCP server Zod validation — schema-first tool definitions — the foundational layer these patterns build on
- MCP server type safety — TypeScript patterns for safe handlers — earlier-stage TypeScript patterns before this advanced tier
- AliveMCP blog — more guides on MCP server architecture, deployment, and operations