Guide · TypeScript
MCP server type safety
TypeScript's basic type annotations catch missing properties and wrong basic types. But an MCP server has additional correctness requirements that basic annotations don't cover: a tool dispatch switch that silently falls through when a new tool name is added; a handler that passes a userId string where a productId is expected; an object with a hardcoded type: 'text' field that TypeScript widens to string instead of the literal type. This guide covers the TypeScript patterns that close these gaps: discriminated unions for type-safe tool results, branded types for nominal ID safety, exhaustive switch for complete tool dispatch, and the satisfies operator for schema objects.
TL;DR
Use as const for tool name arrays to get literal types; use a Record<ToolName, Handler> registry with keyof derivation so adding a tool to the schema forces a handler addition. Model success/error results as a discriminated union ({ ok: true; data: T } | { ok: false; message: string }) so callers must handle both cases. Create branded types for ID strings (type UserId = string & { _brand: 'UserId' }) to prevent ID mix-ups. Use the satisfies operator to type-check tool definition objects without widening them. Add an exhaustive check at the end of every tool switch statement.
Discriminated unions for tool results
Tool handler internals often produce a result that is either success data or an error. A plain string | null union forces callers to guess which case applies. A discriminated union makes the decision point explicit and lets TypeScript narrow the type in each branch.
// Discriminated union result type
type ToolResult<T> =
| { ok: true; data: T }
| { ok: false; message: string };
// Internal helper that returns a discriminated union
async function fetchUser(userId: UserId): Promise<ToolResult<User>> {
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) return { ok: false, message: `User ${userId} not found.` };
return { ok: true, data: user };
}
// In the tool handler — TypeScript narrows in each branch
const result = await fetchUser(parsed.data.userId);
if (!result.ok) {
return {
content: [{ type: 'text', text: result.message }],
isError: true,
};
}
// result.data is User here — fully typed, no null check needed
return {
content: [{ type: 'text', text: JSON.stringify(result.data) }],
};
The discriminated union forces every caller to handle both branches. Without it, it's easy to access result.data without checking for the error case, producing a runtime crash on the first not-found ID.
Branded types for nominal ID safety
TypeScript's type system is structural — two string aliases are interchangeable even when they represent different domains. This means passing a productId to a function expecting a userId is a valid TypeScript program that produces a runtime error or silent data corruption. Branded types add a nominal tag that makes the types structurally distinct.
// Branded type helpers
type Brand<T, B> = T & { readonly _brand: B };
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
type OrgId = Brand<string, 'OrgId'>;
// Constructor functions — validate and cast
function toUserId(raw: string): UserId {
if (!/^usr_[0-9a-z]{16}$/.test(raw)) {
throw new Error(`Invalid UserId format: ${raw}`);
}
return raw as UserId;
}
// A function that requires UserId — rejects ProductId at compile time
async function getUser(id: UserId): Promise<User> {
return db.users.findUniqueOrThrow({ where: { id } });
}
// In the tool handler, after Zod validation:
const userId = toUserId(parsed.data.userId); // validates format
const user = await getUser(userId); // TypeScript accepts
// await getUser(parsed.data.productId); // TypeScript ERROR — good
The constructor function toUserId combines runtime format validation with the type cast, so a branded type is always structurally valid. The Zod schema validates that the format is correct before the cast.
Exhaustive switch for tool dispatch
A CallToolRequest handler that uses a switch statement on request.params.name silently falls through to the default case when a new tool is added to ListTools but no case is added to the switch. The exhaustive check pattern makes TypeScript catch this at compile time.
// Derive ToolName from the schema registry — keeps the type in sync
const TOOL_NAMES = ['search_users', 'get_user', 'delete_user'] as const;
type ToolName = (typeof TOOL_NAMES)[number];
// Exhaustive check helper
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${x as string}`);
}
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name as ToolName;
switch (name) {
case 'search_users': {
const parsed = SearchSchema.safeParse(request.params.arguments);
if (!parsed.success) return validationError(parsed.error);
return handlers.searchUsers(parsed.data);
}
case 'get_user': {
const parsed = GetUserSchema.safeParse(request.params.arguments);
if (!parsed.success) return validationError(parsed.error);
return handlers.getUser(parsed.data);
}
case 'delete_user': {
const parsed = DeleteUserSchema.safeParse(request.params.arguments);
if (!parsed.success) return validationError(parsed.error);
return handlers.deleteUser(parsed.data);
}
default:
// If ToolName has a case not handled above, TypeScript errors here
return assertNever(name);
}
});
When 'create_user' is added to TOOL_NAMES, ToolName becomes 'search_users' | 'get_user' | 'delete_user' | 'create_user'. The switch no longer exhausts all cases, and assertNever(name) receives a 'create_user' string instead of never — TypeScript reports a type error before the code runs.
The satisfies operator for tool definitions
When building a tool definition object, casting with as loses type information; annotating with : ToolDefinition widens the type. The satisfies operator (TypeScript 4.9+) checks that a value matches a type without widening it, preserving the literal types in the object.
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const SEARCH_USERS_TOOL = {
name: 'search_users', // literal type 'search_users', not string
description: 'Search for users by name or email.',
inputSchema: zodToJsonSchema(SearchSchema),
} satisfies Tool;
// Using 'as Tool' would widen name to string — this preserves the literal
type SearchToolName = typeof SEARCH_USERS_TOOL.name; // 'search_users'
// Collect all tool definitions
const ALL_TOOLS = [SEARCH_USERS_TOOL, GET_USER_TOOL, DELETE_USER_TOOL] as const;
type ToolName = (typeof ALL_TOOLS)[number]['name'];
// 'search_users' | 'get_user' | 'delete_user' — derived, stays in sync
Deriving ToolName from the actual tool definition array (instead of a separate string literal union) means there's one source of truth — add a tool to ALL_TOOLS and ToolName updates automatically.
Type-safe tool registry with inferred handler types
Combining Zod schemas, z.infer, and a mapped-type registry gives a fully type-safe dispatch table where each handler receives exactly the type its schema infers.
import { z } from 'zod';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
const SCHEMAS = {
search_users: z.object({ query: z.string().min(1), page: z.number().int().positive().default(1) }),
get_user: z.object({ userId: z.string().uuid() }),
delete_user: z.object({ userId: z.string().uuid(), confirm: z.literal(true) }),
} as const;
type ToolSchemas = typeof SCHEMAS;
type ToolName = keyof ToolSchemas;
// Each handler receives the inferred type for its schema
type ToolHandler<N extends ToolName> = (
input: z.infer<ToolSchemas[N]>
) => Promise<CallToolResult>;
// Mapped type enforces one handler per tool name
type ToolHandlerMap = {
[N in ToolName]: ToolHandler<N>;
};
const handlers: ToolHandlerMap = {
search_users: async ({ query, page }) => { /* ... */ return { content: [] } },
get_user: async ({ userId }) => { /* ... */ return { content: [] } },
delete_user: async ({ userId, confirm }) => { /* ... */ return { content: [] } },
// Adding a new tool to SCHEMAS without adding a handler here is a compile error
};
Type-safe content arrays
The MCP content array in a tool result can contain TextContent, ImageContent, and EmbeddedResource. TypeScript imports from the SDK give the correct types.
import type {
TextContent,
ImageContent,
CallToolResult,
} from '@modelcontextprotocol/sdk/types.js';
function textResult(text: string): CallToolResult {
const content: TextContent = { type: 'text', text };
return { content: [content] };
}
function imageResult(data: string, mimeType: string): CallToolResult {
const content: ImageContent = { type: 'image', data, mimeType };
return { content: [content] };
}
function errorResult(message: string): CallToolResult {
return {
content: [{ type: 'text', text: message } satisfies TextContent],
isError: true,
};
}
The satisfies TextContent on the final example catches a typo in type at compile time ('text' misspelled as 'txet' would be a TypeScript error rather than a silent runtime mismatch).
Common type safety pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
Casting request.params.name as ToolName without checking | Unhandled tool name reaches assertNever at runtime | Validate the tool name is in the registry before casting; throw a typed error if not |
Widening inputSchema with as any | Lost type information; errors surface at runtime not compile time | Use zodToJsonSchema(schema) as Record<string, unknown> — preserves structure |
| Parallel schema and interface definitions | Drift when one is updated | Use z.infer<typeof schema> exclusively — single source of truth |
Mutable tool name array without as const | ToolName is string instead of union of literals | Add as const to the array declaration |
| Non-exhaustive switch with no default | New tool silently returns undefined | Add default: assertNever(name) — compile error on new tool |
Testing type-safe handlers
Unit tests with InMemoryTransport verify runtime behavior; TypeScript compilation verifies type correctness. Run tsc --noEmit in CI to catch type errors without producing build output. The exhaustive check means that if a test was written for a tool and the tool is later removed or renamed, the test references a literal that no longer exists in ToolName — another compile-time catch. See MCP server Vitest for CI pipeline setup that combines tsc --noEmit with test execution.
Related questions
Do I need branded types if I'm already using Zod UUIDs?
They serve different purposes. Zod's z.string().uuid() validates that a string looks like a UUID at runtime. Branded types ensure that a UserId UUID cannot be passed where a ProductId UUID is expected at compile time. Both checks together give the strongest guarantees: Zod ensures the format is valid, the branded type ensures the value is the right kind of ID. If your domain has multiple ID types (users, products, orders), branded types add meaningful safety. If all IDs are of one type, the benefit is smaller.
Can I use the satisfies operator with older TypeScript versions?
The satisfies operator was introduced in TypeScript 4.9. For older versions, use an explicit type annotation (const tool: Tool = { ... }) and accept the widening, or use a helper function that accepts Tool as a parameter type and returns it, which preserves the literal types through inference: function defineTool<T extends Tool>(t: T): T { return t; }.
How do I handle tool names that come from a database or config file?
Dynamic tool names registered at startup should still be typed. Use a validated set: read the config, validate each name against the set of known tool names (using a Zod z.enum() over the TOOL_NAMES array), and fail startup if an unknown name appears. This converts a runtime crash on an unknown tool call into a startup-time validation failure, which is much easier to debug.
Further reading
- MCP server Zod validation — derive TypeScript types and inputSchema from one schema
- MCP server input validation — layered runtime validation and sanitization
- MCP server TypeScript — project setup, tsconfig, and type-checking
- MCP server error handling — when to return isError vs. throw
- MCP server dependency injection — typed dependency interfaces for testable handlers
- MCP server unit testing — testing typed handlers with InMemoryTransport
- MCP server SDK — official SDK types and patterns
- AliveMCP — external MCP protocol monitoring for deployed servers