TypeScript guide · 2026-06-24 · Advanced TypeScript Patterns

TypeScript Type Safety for MCP Servers: Guards, Mapped Types, Satisfies, Template Literals, and Utility Types

The MCP SDK delivers every tool's arguments as unknown — the JSON-decoded payload from the protocol layer. This is the correct design choice: the SDK cannot know the shape of arguments for a tool it has never seen. But it leaves every MCP server author with the same problem: how do you convince TypeScript to enforce argument shapes, handler completeness, and naming conventions across a server with ten, twenty, or fifty tools? Five techniques answer different facets of that problem. Type guards narrow unknown to a typed interface at the handler boundary. Mapped types build a handler registry the compiler checks for completeness. The satisfies operator validates tool definitions without losing literal types needed by that registry. Template literal types encode naming conventions as a compile-time constraint. Custom utility types tie them all together into a reusable layer you write once and use across every tool. This guide synthesizes all five into a single pattern that gives you end-to-end type safety from tool definition to tool handler, with zero as-casts and a compiler that catches every gap.

The five techniques at a glance

Technique What it enforces Where in the server TypeScript version
Type guards Argument shape is correct before access Inside every tool handler All versions
Mapped types Handler map covers every registered tool Dispatch table construction All versions
satisfies operator Tool definitions are valid without widening names to string Tool definitions array 4.9+
Template literal types Tool names follow namespace_action convention Tool name definitions 4.1+
Utility types Argument and handler types derived from definitions, not repeated Shared type layer All versions

Why unknown is the right problem to solve

The Model Context Protocol runs over JSON-RPC 2.0. When an LLM calls a tool, the arguments arrive as a JSON object in the params.arguments field of a tools/call request. The MCP SDK decodes this JSON and types the result as Record<string, unknown> | undefined. There is no way to make this more specific at the protocol layer — the SDK cannot inspect a tool schema defined in your server's module scope at the type level.

The consequence is that every tool handler in every TypeScript MCP server begins the same way: it receives an unknown value and must narrow it before doing anything useful. The naive approach is an as-cast:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  if (name === "files_read") {
    const { path } = args as { path: string };  // unsafe — if args is {path: 42}, this compiles
    return readFile(path);
  }
});

The cast silences TypeScript. It does not validate. If the LLM passes { "path": 42 }, the cast succeeds, path is 42 at runtime, and your handler throws an unexpected type error that surfaces as a failed tool call — not a validation error with a helpful message. The five techniques in this guide eliminate every unsafe cast while adding compiler enforcement that grows with the server.

Technique 1: Type guards — narrowing unknown at the handler boundary

Type guards are the first and most important technique. They run at runtime, validate the argument shape, and return a typed value — or a structured error the LLM can use to retry with correct arguments.

The correct pattern uses Zod's safeParse rather than a manual type predicate for argument schemas, because Zod produces a structured ZodError that becomes a useful error message in the isError: true MCP response:

import { z } from "zod";

const FilesReadArgs = z.object({
  path: z.string().min(1),
  encoding: z.enum(["utf8", "base64"]).default("utf8"),
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: rawArgs } = request.params;

  if (name === "files_read") {
    const parsed = FilesReadArgs.safeParse(rawArgs ?? {});
    if (!parsed.success) {
      return {
        isError: true,
        content: [{ type: "text", text: parsed.error.message }],
      };
    }
    // parsed.data is { path: string; encoding: "utf8" | "base64" }
    return readFile(parsed.data.path, parsed.data.encoding);
  }
});

When the LLM passes wrong argument types, it receives a Zod error message listing what was wrong and what was expected. This is dramatically more useful than a generic "TypeError: Cannot read properties of undefined" stack trace, and it allows an agentic workflow to self-correct on the next attempt.

For multi-tool dispatch, combine Zod guards with discriminated union narrowing so the compiler verifies exhaustiveness. This is the foundation the other four techniques build on — no matter what dispatch pattern you use above, the final handler always ends with a safeParse call that turns unknown into a typed interface.

Technique 2: Mapped types — a handler registry the compiler checks for completeness

Once you have type guards at each handler boundary, the next problem is the dispatch table itself. A plain Record<string, (args: any) => Promise<CallToolResult>> lets you forget a handler for a new tool, or leave a stale handler when you rename a tool. Neither mistake is caught at compile time — both are caught at runtime by a live LLM call.

Mapped types make the dispatch table self-enforcing. Extract tool names as a literal union from the tools array, then map each name to its handler type:

// Extract literal names from the tools tuple
type ToolName = typeof tools[number]["name"];
// = "files_read" | "files_write" | "search"

// Map each name to a handler that accepts the right argument shape
type ToolHandlerMap = {
  [K in ToolName]: (args: Record<string, unknown>) => Promise<CallToolResult>;
};

// The compiler now enforces that EVERY tool has a handler
const handlers: ToolHandlerMap = {
  files_read: async (rawArgs) => { /* ... */ },
  files_write: async (rawArgs) => { /* ... */ },
  search: async (rawArgs) => { /* ... */ },
  // Forgot search? Compiler error: Property 'search' is missing
  // Renamed files_read to file_read? Compiler error: Object literal may only specify known properties
};

With the tool-specific utility types from technique 5, the handler types become more precise — but even without that, the mapped type eliminates the two most common multi-tool bugs: missing handlers and stale handler keys. Wire the map into your setRequestHandler dispatch:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: rawArgs } = request.params;
  const handler = handlers[name as ToolName];
  if (!handler) {
    return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
  }
  return handler(rawArgs ?? {});
});

The as ToolName cast on the incoming name is acceptable here — it is the one boundary where you cannot avoid a cast because the protocol layer types name as string. The handler map enforces the shape on the other side of that cast.

Technique 3: The satisfies operator — validate without widening

Mapped types depend on extracting typeof tools[number]["name"] as a literal union — "files_read" | "files_write" | "search", not just string. Getting that extraction to work correctly requires the tools array to preserve literal types, which creates a dilemma with the standard approaches.

Using an explicit type annotation loses literals:

const tools: ToolDefinition[] = [
  { name: "files_read", description: "...", inputSchema: { /* ... */ } },
];
// typeof tools[number]["name"] = string  ← too wide, mapped type breaks

Using as const preserves literals but skips validation:

const tools = [
  { name: "files_read", description: "...", inputSchema: { /* ... */ } },
] as const;
// typeof tools[number]["name"] = "files_read"  ← correct
// But: no compiler check that every element is a valid ToolDefinition

The satisfies operator (TypeScript 4.9+) resolves the dilemma. It validates the value against a type without widening the inferred type:

const tools = [
  { name: "files_read", description: "Read a file", inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } },
  { name: "files_write", description: "Write a file", inputSchema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
  { name: "search", description: "Search files", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
] satisfies ToolDefinition[];
// Compiler: ✓ each element matches ToolDefinition
// typeof tools[number]["name"] = "files_read" | "files_write" | "search"  ← literals preserved

Misspell a required field? Compiler error at the definition site, not at the first tool call. Add an extra property that does not exist in ToolDefinition? Compiler error. The satisfies operator is the linchpin that makes the mapped type registry work correctly — without it, the registry either loses literal types (explicit annotation) or loses validation (as const).

One requirement: TypeScript 4.9 or later. Modern Node.js projects targeting ES2022 and using a tsconfig with "target": "ES2022" already satisfy this. Check with tsc --version.

Technique 4: Template literal types — naming conventions as compile-time constraints

MCP tool names are plain strings at the protocol layer. The MCP specification recommends a namespace_action convention — files_read, files_write, search_query — but the SDK accepts any string. Without additional enforcement, a server with twenty tools will eventually have an inconsistently named tool that confuses LLMs trying to select from the list.

Template literal types encode the naming convention as a compile-time constraint. Define namespace and action unions, then compute the valid tool name type from them:

type Namespace = "files" | "search" | "git" | "db";
type Action = "read" | "write" | "list" | "delete" | "query" | "commit";
type ToolName = `${Namespace}_${Action}`;
// = "files_read" | "files_write" | "files_list" | "files_delete" | "files_query" | "files_commit"
//   | "search_read" | "search_write" | ...  (all 24 combinations)

Use Extract<> to pick only the combinations that make sense for your server:

type ValidToolName =
  | Extract<ToolName, `files_${"read" | "write" | "list" | "delete"}`>
  | Extract<ToolName, `search_${"query"}`>
  | Extract<ToolName, `git_${"commit" | "list"}`>;
// = "files_read" | "files_write" | "files_list" | "files_delete" | "search_query" | "git_commit" | "git_list"

Wire this into the satisfies-validated tools array. The tools array now serves as both a runtime value and a compile-time source of truth for the constraint:

interface ToolDefinition {
  name: ValidToolName;  // ← only valid convention names accepted
  description: string;
  inputSchema: object;
}

const tools = [
  { name: "files_read", /* ... */ },
  { name: "filesRead", /* ... */ },  // Compiler error: Type '"filesRead"' is not assignable to type 'ValidToolName'
] satisfies ToolDefinition[];

Template literal types also let you build conditional types that extract information from tool names. T extends \`${infer NS}_${string}\` ? NS : never extracts the namespace prefix from any valid tool name — useful for grouping handlers, generating per-namespace health checks, or routing tool calls to sub-servers.

Technique 5: Custom utility types — derive handler types from tool definitions

With a satisfies-validated tools array, literal tool names, and a mapped handler registry, one problem remains: each handler still receives Record<string, unknown> and must cast or parse to the specific argument type. The mapped type registry knows which handler handles which tool, but it does not (yet) know what argument type each tool expects. Custom utility types close this gap by deriving per-handler argument types from the tools array itself.

Five utility types cover the common cases:

// Extract the tool definition for a given tool name from the tools tuple
type PickTool<T extends readonly ToolDefinition[], Name extends string> =
  Extract<T[number], { name: Name }>;

// Extract the inputSchema type for a given tool name
type ToolSchema<T extends readonly ToolDefinition[], Name extends string> =
  PickTool<T, Name>["inputSchema"];

// Infer the TypeScript type from a Zod schema
type InferZodInput<S extends z.ZodTypeAny> = z.infer<S>;

// The return type of every tool handler
type ToolResult = { content: ContentBlock[]; isError?: boolean };

// Generate the complete handler map type from the tools tuple
type ToolHandlerMap<T extends readonly ToolDefinition[]> = {
  [K in T[number]["name"]]: (args: Record<string, unknown>) => Promise<ToolResult>;
};

In practice, handler argument types come from Zod schemas rather than from the JSON Schema in the tool definition (because Zod gives you a TypeScript type as a byproduct of defining the runtime validator). The InferZodInput utility type extracts that TypeScript type so you can use it as the argument type in the mapped handler type:

const FilesReadArgsSchema = z.object({
  path: z.string().min(1),
  encoding: z.enum(["utf8", "base64"]).default("utf8"),
});

// Derived — no duplication
type FilesReadArgs = InferZodInput<typeof FilesReadArgsSchema>;
// = { path: string; encoding: "utf8" | "base64" }

The single source of truth pattern: define the Zod schema once, derive the TypeScript type from it, use the TypeScript type in the handler signature, and use the Zod schema in the handler body to validate unknown arguments. Zero duplication, zero drift.

Full working example — all five techniques together

The following is a complete, type-safe MCP server that uses all five techniques. It handles three tools, validates every argument, enforces naming conventions, and uses a mapped registry so the compiler flags any missing handler:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

// ── 1. Template literal naming convention ─────────────────────────────────────
type Namespace = "files" | "search";
type Action    = "read"  | "write" | "query";
type ToolName  = `${Namespace}_${Action}`;

// ── 2. Tool definition interface (name must match convention) ─────────────────
interface ToolDefinition {
  name: ToolName;
  description: string;
  inputSchema: object;
}

// ── 3. Satisfies — validate definitions, preserve literal types ───────────────
const tools = [
  {
    name: "files_read",
    description: "Read a file at the given path and return its contents.",
    inputSchema: {
      type: "object",
      properties: {
        path:     { type: "string", minLength: 1 },
        encoding: { type: "string", enum: ["utf8", "base64"] },
      },
      required: ["path"],
    },
  },
  {
    name: "files_write",
    description: "Write content to a file at the given path.",
    inputSchema: {
      type: "object",
      properties: {
        path:    { type: "string", minLength: 1 },
        content: { type: "string" },
      },
      required: ["path", "content"],
    },
  },
  {
    name: "search_query",
    description: "Search for files matching a query string.",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", minLength: 1 },
        limit: { type: "number", minimum: 1, maximum: 100 },
      },
      required: ["query"],
    },
  },
] satisfies ToolDefinition[];
// Compiler validates every element matches ToolDefinition
// typeof tools[number]["name"] = "files_read" | "files_write" | "search_query"

// ── 4. Zod schemas (single source of truth for argument types) ────────────────
const FilesReadArgs  = z.object({ path: z.string().min(1), encoding: z.enum(["utf8", "base64"]).default("utf8") });
const FilesWriteArgs = z.object({ path: z.string().min(1), content: z.string() });
const SearchQueryArgs = z.object({ query: z.string().min(1), limit: z.number().int().min(1).max(100).default(10) });

// ── 5. Utility types — derive handler types from tool names ───────────────────
type InferZodInput<S extends z.ZodTypeAny> = z.infer<S>;

type FilesReadArgsT   = InferZodInput<typeof FilesReadArgs>;
type FilesWriteArgsT  = InferZodInput<typeof FilesWriteArgs>;
type SearchQueryArgsT = InferZodInput<typeof SearchQueryArgs>;

type AllToolNames = typeof tools[number]["name"];

// ── 6. Mapped type handler registry — compiler enforces completeness ───────────
type ToolHandlerMap = {
  [K in AllToolNames]: (rawArgs: Record<string, unknown>) => Promise<{ content: { type: string; text: string }[]; isError?: boolean }>;
};

const handlers: ToolHandlerMap = {
  files_read: async (rawArgs) => {
    const parsed = FilesReadArgs.safeParse(rawArgs);
    if (!parsed.success) return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
    const { path, encoding }: FilesReadArgsT = parsed.data;
    // ... implementation
    return { content: [{ type: "text", text: `Read ${path} as ${encoding}` }] };
  },

  files_write: async (rawArgs) => {
    const parsed = FilesWriteArgs.safeParse(rawArgs);
    if (!parsed.success) return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
    const { path, content }: FilesWriteArgsT = parsed.data;
    // ... implementation
    return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
  },

  search_query: async (rawArgs) => {
    const parsed = SearchQueryArgs.safeParse(rawArgs);
    if (!parsed.success) return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
    const { query, limit }: SearchQueryArgsT = parsed.data;
    // ... implementation
    return { content: [{ type: "text", text: `Found 0 results for "${query}" (limit ${limit})` }] };
  },
  // Forget files_write? Compiler: Property 'files_write' is missing in type...
  // Add a fourth tool but no handler? Compiler: Property 'new_tool' is missing...
};

// ── 7. Wire into the MCP server ───────────────────────────────────────────────
const server = new Server({ name: "typed-file-server", version: "1.0.0" });

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: rawArgs } = request.params;
  const handler = (handlers as Record<string, typeof handlers[AllToolNames]>)[name];
  if (!handler) {
    return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
  }
  return handler(rawArgs ?? {});
});

This server has zero as TypeName-casts inside any handler. Every argument shape error is caught by Zod and returned as a structured error message. Every missing handler is a compile error. Every tool name that violates the convention is a compile error. Adding a new tool requires updating the tools array (validated by satisfies), adding a Zod schema, and adding a handler (enforced by the mapped type) — the compiler walks you through every step.

What this does not solve — and what AliveMCP adds

TypeScript type safety eliminates a class of bugs before deployment: wrong argument types, missing handlers, naming convention violations, schema drift between definition and implementation. These are build-time bugs — they prevent the server from compiling if you make them.

Runtime bugs are different. After deployment, the compiler is gone. What can still go wrong:

None of these are detectable by TypeScript. They are detectable by AliveMCP, which runs a protocol-layer probe that verifies the server is accepting connections, completing the MCP handshake, and responding to tool list requests — all at 60-second intervals. When a production MCP server silently stops working, AliveMCP is the signal that tells you before your LLM callers do.

The right posture: use the five TypeScript techniques in this guide to eliminate build-time bugs, and use AliveMCP to catch the runtime failures that TypeScript cannot see.

Applying this pattern incrementally

If you are adding type safety to an existing MCP server rather than building from scratch, apply the techniques in dependency order:

  1. Type guards first. Add z.safeParse() to each handler. This is mechanical and adds immediate value — Zod error messages are dramatically better than unhandled type errors. Each guard is independent; you can add them one handler at a time.
  2. Satisfies operator second. Convert the tools array from an explicit annotation or as const to the satisfies ToolDefinition[] pattern. This requires TypeScript 4.9+ but is otherwise a one-line change that preserves runtime behavior.
  3. Mapped type registry third. Extract tool names and build the ToolHandlerMap. At this point the compiler will tell you about any handler you forgot. Fix them.
  4. Template literal types fourth. Add the naming convention constraint to the ToolDefinition interface. If your existing tool names violate the convention, fix them now — or adjust the convention type to match your actual naming scheme.
  5. Utility types last. Once the pattern is stable, extract the five utility types into a shared module. This step has the most upfront cost but pays off when the server grows past twenty tools.

Each step is independently useful. A server with only type guards and no mapped registry is still much safer than one with as-casts. The full pattern is a destination, not a prerequisite.

Further reading