Guide · TypeScript

MCP server mapped types

A type-safe MCP server tool registry maps each tool name to its argument type and its handler function. TypeScript mapped types make this registry self-enforcing at compile time: add a new tool definition, and the compiler tells you to add a handler; rename a tool, and the compiler flags every handler that used the old name. This guide shows how to build that registry using keyof, in, and mapped type constraints.

TL;DR

Define tools as a const tuple, extract tool names with typeof tools[number]['name'], then build a { [K in ToolName]: (args: ToolArgs<K>) => Promise<CallToolResult> } handler map. The compiler enforces completeness and argument types for every handler. Wire the map into the MCP setRequestHandler dispatch and use AliveMCP to monitor each tool's error rate in production.

The basic problem — untyped dispatch tables

The naive approach to multi-tool dispatch is a plain object keyed by tool name:

// Untyped dispatch table — common but unsafe
const handlers: Record<string, (args: any) => Promise<CallToolResult>> = {
  files_read: async (args) => { /* args is any — no type checking */ },
  search:     async (args) => { /* same problem */ },
  files_write: async (args) => { /* can't enforce argument shape */ },
};

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

This pattern has three failure modes. First, args is typed as any inside every handler — TypeScript will not catch argument shape errors, missing required fields, or wrong property types. Second, the compiler does not check that handlers covers all tools registered with the server — you can forget to add a handler and only discover the gap at runtime. Third, renaming a tool in the tools array does not automatically flag the stale key in handlers. Mapped types eliminate all three failure modes.

Defining the tools tuple with as const

Start by defining your tools array with as const. This tells TypeScript to infer string literal types for each name field rather than widening to string:

import { z } from "zod";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

// Schemas for each tool's arguments
const FilesReadArgsSchema = z.object({
  path: z.string().min(1),
  encoding: z.enum(["utf8", "base64"]).default("utf8"),
});

const SearchArgsSchema = z.object({
  query: z.string().min(1),
  limit: z.number().int().min(1).max(100).default(10),
});

const FilesWriteArgsSchema = z.object({
  path: z.string().min(1),
  content: z.string(),
  create_dirs: z.boolean().default(false),
});

// The tools tuple — as const preserves literal name types
const tools = [
  {
    name: "files_read",
    description: "Read a file from the filesystem",
    inputSchema: {
      type: "object" as const,
      properties: {
        path: { type: "string" },
        encoding: { type: "string", enum: ["utf8", "base64"] },
      },
      required: ["path"],
    },
    argsSchema: FilesReadArgsSchema,
  },
  {
    name: "search",
    description: "Search indexed content",
    inputSchema: {
      type: "object" as const,
      properties: {
        query: { type: "string" },
        limit: { type: "number" },
      },
      required: ["query"],
    },
    argsSchema: SearchArgsSchema,
  },
  {
    name: "files_write",
    description: "Write content to a file",
    inputSchema: {
      type: "object" as const,
      properties: {
        path: { type: "string" },
        content: { type: "string" },
        create_dirs: { type: "boolean" },
      },
      required: ["path", "content"],
    },
    argsSchema: FilesWriteArgsSchema,
  },
] as const;

Each tool carries its Zod schema alongside the MCP inputSchema descriptor. This keeps the validation logic co-located with the tool definition — a single source of truth for what the tool accepts.

Extracting ToolName as a string literal union

With as const on the array, TypeScript infers each element's name as a string literal. You can lift those literals into a union type with an indexed access type:

// typeof tools[number] = the union of all element types in the tuple
// typeof tools[number]['name'] = the union of all name literal types
type ToolName = typeof tools[number]["name"];
// Inferred as: "files_read" | "search" | "files_write"

// Also extract the full tool entry for a given name using Extract
type ToolEntry<K extends ToolName> = Extract<typeof tools[number], { name: K }>;

// And extract the Zod schema for a given tool name
type ToolSchema<K extends ToolName> = ToolEntry<K>["argsSchema"];

// And the inferred argument type from that schema
type ToolArgs<K extends ToolName> = z.infer<ToolSchema<K>>;

// Examples:
// ToolArgs<"files_read"> = { path: string; encoding: "utf8" | "base64" }
// ToolArgs<"search">     = { query: string; limit: number }
// ToolArgs<"files_write"> = { path: string; content: string; create_dirs: boolean }

The Extract<Union, { name: K }> pattern is the critical step. It filters the tools union to the single element whose name is K, giving you the full tool entry including its argsSchema. From there, z.infer<ToolSchema<K>> derives the argument type — no manual interface duplication needed.

Building the ToolHandlerMap mapped type

A mapped type iterates over the keys of a union or object type and produces a new type. Use { [K in ToolName]: ... } to build a handler map that enforces argument types per tool name:

// The complete handler map type
type ToolHandlerMap = {
  [K in ToolName]: (args: ToolArgs<K>) => Promise<CallToolResult>;
};

// Implementing the map — TypeScript checks:
// 1. Every key in ToolName is present
// 2. Each handler's args parameter matches ToolArgs for that key
const toolHandlers: ToolHandlerMap = {
  files_read: async (args) => {
    // args is { path: string; encoding: "utf8" | "base64" } — fully typed
    const data = await fs.readFile(args.path, args.encoding);
    return { content: [{ type: "text", text: data }] };
  },

  search: async (args) => {
    // args is { query: string; limit: number } — fully typed
    const results = await runSearch(args.query, args.limit);
    return { content: [{ type: "text", text: JSON.stringify(results) }] };
  },

  files_write: async (args) => {
    // args is { path: string; content: string; create_dirs: boolean } — fully typed
    if (args.create_dirs) {
      await fs.mkdir(path.dirname(args.path), { recursive: true });
    }
    await fs.writeFile(args.path, args.content, "utf8");
    return { content: [{ type: "text", text: `Wrote ${args.content.length} bytes to ${args.path}` }] };
  },
};

// If you add "files_delete" to the tools tuple without adding it here:
// Error: Property 'files_delete' is missing in type '{ files_read: ...; search: ...; files_write: ...; }'
// but required in type 'ToolHandlerMap'

The compiler now enforces the full handler map at the point of assignment. Adding a tool to the tools tuple automatically widens ToolName, which immediately makes ToolHandlerMap require a new key — and any object typed as ToolHandlerMap will fail to compile until the handler is added.

Compile-time completeness checking

The completeness guarantee only holds if the handler map object is typed as ToolHandlerMap at the point of creation. A few patterns that defeat it:

Pattern Completeness checked? Why
const m: ToolHandlerMap = { ... } Yes Type annotation forces completeness check at assignment
const m = { ... } satisfies ToolHandlerMap Yes satisfies validates without widening — see satisfies guide
const m = { ... } as ToolHandlerMap No as-cast bypasses completeness check
const m: Record<string, Function> = { ... } No Too wide — accepts any string key

Prefer the explicit type annotation (const m: ToolHandlerMap = { ... }) over satisfies when you do not need to access per-key literal types from m itself. Use satisfies when you need both validation and preserved literal inference — see the satisfies operator guide for details.

Wiring the mapped type into setRequestHandler

The final step is connecting the type-safe handler map to the MCP server's setRequestHandler. The key challenge is narrowing the runtime tool name to a ToolName before indexing into the map:

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

const server = new Server({ name: "typed-tools-server", version: "1.0.0" });

// Build a lookup from tool name to its Zod schema at startup
const toolSchemas = Object.fromEntries(
  tools.map((t) => [t.name, t.argsSchema])
) as { [K in ToolName]: ToolSchema<K> };

// Narrowing helper: checks the name against the known set
const toolNameSet = new Set<string>(tools.map((t) => t.name));
function isToolName(name: string): name is ToolName {
  return toolNameSet.has(name);
}

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: tools.map(({ name, description, inputSchema }) => ({
    name,
    description,
    inputSchema,
  })),
}));

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

  if (!isToolName(name)) {
    return {
      isError: true,
      content: [{ type: "text", text: `Unknown tool: ${name}` }],
    };
  }

  // Parse arguments using the tool's schema
  const schema = toolSchemas[name];
  const parsed = schema.safeParse(rawArgs ?? {});

  if (!parsed.success) {
    return {
      isError: true,
      content: [{
        type: "text",
        text: JSON.stringify({
          error: "invalid_arguments",
          tool: name,
          issues: parsed.error.issues.map((i) => ({
            path: i.path.join("."),
            message: i.message,
          })),
        }),
      }],
    };
  }

  // name is ToolName, parsed.data is ToolArgs<typeof name>
  // TypeScript needs a cast here because indexing a mapped type with
  // a union key requires the handler to accept the union of all arg types.
  // The runtime type is correct because we parsed with the matching schema.
  const handler = toolHandlers[name] as (args: unknown) => Promise<CallToolResult>;
  return handler(parsed.data);
});

The single as (args: unknown) cast when invoking the handler is unavoidable: TypeScript cannot prove at the call site that toolHandlers[name] and parsed.data have compatible types when name is the full ToolName union. The safety is established by the mapped type constraint at definition time and by the schema.safeParse validation at runtime — the cast is safe because you used the same schema for both.

Related questions

Can I use this pattern with Zod schemas directly instead of inputSchema descriptors?

Yes. The pattern shown above already co-locates the Zod schema with each tool entry. You can go further and derive the inputSchema JSON Schema descriptor from the Zod schema using zodToJsonSchema from the zod-to-json-schema package — this eliminates the duplication between the Zod schema and the JSON Schema properties block entirely. The mapped type infrastructure works identically; ToolArgs<K> is still derived from z.infer<ToolSchema<K>>.

What about optional tool arguments — does the mapped type handle them?

Zod's .optional() and .default() are transparent to the mapped type. If FilesReadArgsSchema marks encoding as optional with a default, then ToolArgs<"files_read"> infers encoding as required in parsed.data (because Zod applies the default during parsing). Your handler can access args.encoding without optional-chaining even though the LLM may omit the field. The mapped type reflects the post-parse type — after defaults are applied — which is exactly what you want.

Are there performance implications to using mapped types at compile time?

Mapped types are a compile-time construct only — they exist in TypeScript's type checker and are erased in the emitted JavaScript. At runtime, there is no mapped type object, no iteration, no overhead. The tools array and toolHandlers object are plain JavaScript values. The only runtime cost is the toolNameSet.has(name) guard (O(1)) and the Zod safeParse call — both of which you need regardless of how you type the dispatch.

How do I add dynamic tools that are not known at compile time?

Dynamic tools — loaded from a plugin system or a database — cannot participate in the mapped type system because their names are not known at compile time. Handle them in a separate branch: maintain a Map<string, { schema: z.ZodTypeAny; handler: (args: unknown) => Promise<CallToolResult> }> for dynamic tools, and check this map after the static dispatch fails to find a tool name. The static mapped-type dispatch still protects the known tools; the dynamic map handles the rest with a looser type but explicit runtime validation per entry.

Further reading