Guide · TypeScript
TypeScript type composition for MCP servers
The MCP TypeScript SDK uses Zod for tool input schemas and gives you the full TypeScript type system for handler signatures. That combination enables patterns — discriminated unions for multi-mode tools, branded types for safe ID arguments, recursive schemas for tree-shaped data, and the satisfies operator for compile-time schema validation — that eliminate entire classes of runtime errors before your server reaches production.
TL;DR
Use discriminated unions (a mode or action discriminant field) when a tool has fundamentally different behaviors depending on input shape — TypeScript narrows the union in each branch and the exhaustiveness check catches missing cases at compile time. Use branded types to prevent swapping a UserId for an OrgId — the brand is erased at runtime, so there's no performance cost. Use z.lazy() for recursive schemas (trees, nested structures). Use satisfies to validate your static JSON schema objects against the MCP inputSchema type at compile time. For all of this to help in production, pair it with AliveMCP external monitoring — types prevent code bugs; monitoring catches the deployment and configuration failures types can't see.
Discriminated unions for multi-mode tools
A common mistake is combining related but structurally different operations into a single tool with optional fields: { userId?: string; orgId?: string; query?: string }. This is fragile — no compile-time enforcement that the right fields are present for each mode. The discriminated union pattern fixes this:
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Define each mode as a separate schema, tagged by a discriminant
const UserSearchSchema = z.object({
mode: z.literal("user"),
userId: z.string().uuid(),
includeMetadata: z.boolean().optional().default(false),
});
const OrgSearchSchema = z.object({
mode: z.literal("org"),
orgId: z.string().uuid(),
includeMembers: z.boolean().optional().default(false),
});
const FullTextSearchSchema = z.object({
mode: z.literal("fts"),
query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(100).default(20),
});
// Compose into a discriminated union
const SearchSchema = z.discriminatedUnion("mode", [
UserSearchSchema,
OrgSearchSchema,
FullTextSearchSchema,
]);
const server = new McpServer({ name: "search-server", version: "1.0.0" });
server.tool(
"search",
"Search users, orgs, or full-text content",
SearchSchema,
async (args) => {
// TypeScript narrows args in each branch:
switch (args.mode) {
case "user": {
// args.userId is string, args.includeMetadata is boolean — guaranteed
const user = await db.users.findById(args.userId);
return { content: [{ type: "text", text: JSON.stringify(user) }] };
}
case "org": {
// args.orgId is string — no possibility of swapping with userId
const org = await db.orgs.findById(args.orgId);
return { content: [{ type: "text", text: JSON.stringify(org) }] };
}
case "fts": {
const results = await db.search(args.query, args.limit);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
default: {
// Exhaustiveness check — TypeScript errors here if a case is missing
const _exhaustive: never = args;
throw new Error(`Unhandled mode: ${(_exhaustive as { mode: string }).mode}`);
}
}
}
);
The discriminant field (mode) is a plain string that the LLM passes — it needs to be in the tool description so the model knows which value to use for each intent. Keep discriminant values short and semantic: "user", "org", "fts" — not "MODE_USER_SEARCH_V2".
Branded types for safe ID arguments
Tool handlers frequently receive multiple ID arguments: user ID, org ID, resource ID, tenant ID. Without brands, TypeScript treats them all as string — passing a userId where an orgId is expected is a silent runtime bug. Branded types make the mistake a compile error:
// Brand declaration — zero runtime cost, erased by TypeScript compiler
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrgId = Brand<string, "OrgId">;
type ResourceId = Brand<string, "ResourceId">;
// Brand-aware Zod schemas using transform to apply the brand
const UserIdSchema = z.string().uuid().transform((v) => v as UserId);
const OrgIdSchema = z.string().uuid().transform((v) => v as OrgId);
const ResourceIdSchema = z.string().uuid().transform((v) => v as ResourceId);
// Tool that requires both IDs — can't swap them accidentally
const GetResourceSchema = z.object({
userId: UserIdSchema,
orgId: OrgIdSchema,
resourceId: ResourceIdSchema,
});
server.tool(
"get_resource",
"Get a resource owned by a user within an org",
GetResourceSchema,
async ({ userId, orgId, resourceId }) => {
// userId is UserId, orgId is OrgId — TypeScript enforces correct usage
const resource = await db.resources.get(resourceId, { userId, orgId });
return { content: [{ type: "text", text: JSON.stringify(resource) }] };
}
);
// Type-safe DB layer — won't compile if you swap userId/orgId
async function getResource(
id: ResourceId,
ctx: { userId: UserId; orgId: OrgId }
): Promise<Resource> {
// Implementation — can pass userId/orgId to sub-functions with confidence
return db.query("SELECT * FROM resources WHERE id = ? AND user_id = ? AND org_id = ?",
[id, ctx.userId, ctx.orgId]);
}
The brand is a phantom type — it only exists in the type system. At runtime, userId is just a string. No boxing, no allocation, no performance overhead. The transform in the Zod schema is a cast, not a conversion — the JSON-RPC layer still receives a plain UUID string from the LLM client.
Recursive schemas with z.lazy()
File-system trees, org hierarchies, nested comment threads, and AST nodes are all recursive structures. Zod handles them with z.lazy() for the recursive reference:
// Recursive tree node — TypeScript requires an explicit type annotation
// because z.lazy prevents automatic inference
interface TreeNode {
id: string;
name: string;
children: TreeNode[];
}
const TreeNodeSchema: z.ZodType<TreeNode> = z.object({
id: z.string(),
name: z.string(),
children: z.lazy(() => z.array(TreeNodeSchema)),
});
server.tool(
"create_tree",
"Create a nested tree of nodes (recursive structure allowed)",
{ root: TreeNodeSchema, maxDepth: z.number().int().min(1).max(10).default(5) },
async ({ root, maxDepth }) => {
const nodeCount = countNodes(root); // root is TreeNode — fully typed
if (nodeCount > 1000) {
return { isError: true, content: [{ type: "text", text: "Tree too large (> 1000 nodes)" }] };
}
const result = await db.trees.create(root, { maxDepth });
return { content: [{ type: "text", text: JSON.stringify({ treeId: result.id, nodeCount }) }] };
}
);
Caveats with recursive schemas: Zod validates each node, so deeply nested inputs have O(n) validation cost proportional to node count. Always cap depth and node count at the handler level to prevent stack overflows and runaway validation time. For inputs that might be very large, consider accepting a flat array of { id, parentId, name } objects and reconstructing the tree server-side — this avoids recursive Zod schema overhead entirely.
The satisfies operator for compile-time inputSchema validation
MCP tools can accept a raw JSON Schema object instead of a Zod schema for the inputSchema parameter. The satisfies operator (TypeScript 4.9+) lets you validate the shape of that static object at compile time without widening its type:
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
// Static JSON Schema — satisfies verifies structure, preserves literal types
const searchInputSchema = {
type: "object",
properties: {
query: { type: "string", minLength: 1, maxLength: 500 },
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
filters: {
type: "object",
properties: {
status: { type: "string", enum: ["active", "inactive", "archived"] },
createdAfter: { type: "string", format: "date-time" },
},
additionalProperties: false,
},
},
required: ["query"],
additionalProperties: false,
} satisfies Tool["inputSchema"];
// Compile error if the schema doesn't match Tool["inputSchema"] shape
// Unlike `as Tool["inputSchema"]`, satisfies doesn't suppress type errors
// Use the validated schema in tool registration
server.tool(
"search_records",
"Search records with optional filters",
searchInputSchema,
async (args) => {
// args is typed as the schema's output shape
return { content: [{ type: "text", text: "results" }] };
}
);
The difference between satisfies and as: as Tool["inputSchema"] is a cast that suppresses errors — a malformed schema object won't fail to compile. satisfies Tool["inputSchema"] is a structural check — it errors at compile time if the object doesn't match the schema type, while preserving the object's literal type for downstream inference. Use satisfies when you want compile-time validation without losing type information.
Composing input schemas across tools
Large MCP servers often have many tools that share common argument subsets — pagination, date ranges, auth context. Define shared schema fragments and compose them with z.merge() and z.extend():
// Shared schema fragments
const PaginationSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
});
const DateRangeSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
}).refine(
({ startDate, endDate }) => !startDate || !endDate || startDate <= endDate,
{ message: "startDate must be before endDate", path: ["startDate"] }
);
const TenantSchema = z.object({
orgId: OrgIdSchema, // branded type from earlier
});
// Compose: list_invoices needs all three
const ListInvoicesSchema = TenantSchema.merge(PaginationSchema).merge(DateRangeSchema).extend({
status: z.enum(["draft", "pending", "paid", "overdue"]).optional(),
});
// list_orders shares pagination and tenant but not date range
const ListOrdersSchema = TenantSchema.merge(PaginationSchema).extend({
fulfillmentStatus: z.enum(["pending", "shipped", "delivered"]).optional(),
});
server.tool("list_invoices", "List invoices for an org", ListInvoicesSchema, async (args) => {
// args.orgId: OrgId, args.page: number, args.startDate: string | undefined, etc.
const invoices = await db.invoices.list(args);
return { content: [{ type: "text", text: JSON.stringify(invoices) }] };
});
server.tool("list_orders", "List orders for an org", ListOrdersSchema, async (args) => {
const orders = await db.orders.list(args);
return { content: [{ type: "text", text: JSON.stringify(orders) }] };
});
Schema composition keeps validation logic DRY — fix a bug in DateRangeSchema once and it applies to every tool that merges it. The Zod schemas are TypeScript values, so composition is plain function calls; no code generation or build step required.
Monitoring type-safe MCP servers in production
TypeScript's type system prevents a large class of bugs at compile time, but it can't protect you from:
- Schema drift — after a dependency update, a third-party type definition changes and your compiled server's
tools/listresponse silently changes shape. Clients that cached the schema break. - Runtime Zod mismatches — the LLM sends arguments that pass Zod validation but fail a database constraint (foreign key, unique, check). These appear as unhandled errors at runtime.
- Deployment failures — the wrong binary is deployed, the compiled JS has an import error that only appears at module load time, or a secret is missing in the production environment.
AliveMCP continuously probes your server's MCP endpoint, verifies the initialize handshake succeeds, compares the tools/list response hash against baseline, and alerts on any change. A schema change that breaks downstream clients shows up in AliveMCP before you get user reports.
Frequently asked questions
Can I use TypeScript generics to create type-safe tool factory functions?
Yes. A factory function that takes a Zod schema and a handler and returns a fully-typed tool registration is straightforward: function defineTool<T extends z.ZodRawShape>(name: string, description: string, shape: T, handler: (args: z.infer<z.ZodObject<T>>) => Promise<CallToolResult>). The generic T captures the schema shape and flows through to the handler's arg type. This pattern is useful for applying consistent wrapper behavior (auth, logging, error normalization) across all tools without repeating the wrapper at each call site.
Should I use z.discriminatedUnion or z.union for multi-mode tools?
Use z.discriminatedUnion when all variants share a discriminant field with literal values (the common case). It has better TypeScript inference and better error messages — Zod knows which variant to try based on the discriminant value, so it doesn't fall back to testing all variants sequentially. Use z.union only when the variants can't be distinguished by a single literal field — for example, a union of string and number types, or structurally different objects with no common discriminant field.
Do branded types appear in the JSON Schema / inputSchema that the LLM sees?
No — brands are TypeScript phantom types erased at compile time. The Zod schema that McpServer converts to JSON Schema for tools/list sees only z.string().uuid(), not the brand. From the LLM's perspective, a UserId field is just a UUID string. The brand only enforces correct usage in your TypeScript source code — it prevents you from passing a userId where an orgId is expected during development, before the code reaches production.
What's the depth limit for recursive Zod schemas?
Zod itself has no hardcoded recursion limit, but Node.js has a call stack limit (typically ~10,000 frames). A deeply nested recursive schema with 10,000+ levels of nesting would hit a stack overflow during validation. In practice, cap depth at the handler level (return isError: true if depth exceeds your limit) before Zod validation runs. For most real use cases — file trees, org hierarchies, comment threads — a depth cap of 20–50 levels is generous. Validate the raw input depth before passing to Zod if the input is untrusted.
Can I infer TypeScript types from my Zod schemas for use in other parts of the codebase?
Yes — z.infer<typeof MySchema> extracts the TypeScript type. For branded type schemas that use transform(), use z.infer<typeof MySchema> for the output type (after transform, the brand is applied) and z.input<typeof MySchema> for the input type (before transform, plain string). Share these inferred types with your database layer, service functions, and API client types to keep the type surface consistent across the codebase without duplication.
Further reading
- MCP server type safety — Zod schemas and TypeScript strictness
- MCP server branded types — preventing ID confusion in tool arguments
- TypeScript satisfies operator for MCP server inputSchema validation
- MCP server Zod validation — input schemas, refinements, and error formatting
- MCP server tool design — naming, descriptions, and argument shapes
- MCP TypeScript SDK internals — Server class, transport layer, and InMemoryTransport testing
- AliveMCP — uptime and protocol monitoring for MCP servers