Guide · TypeScript

MCP server utility types

The MCP TypeScript SDK provides protocol types but not the higher-level utility types that make a large server type-safe end-to-end. You end up writing (args as { query: string }).query dozens of times, or maintaining a manually typed dispatch table that drifts from your tool definitions. A small set of custom utility types eliminates both problems: define tools once, derive handler types automatically, and let the compiler enforce correctness across the entire server.

TL;DR

Five utility types cover the common cases: ToolArgs<T, Name> extracts the argument type for a named tool from the tools tuple; ToolResult types the return shape of every handler; PickTool<T, Name> extracts one tool definition from the tuple by name; ToolHandlerMap<T> generates the complete handler map type from the tuple; InferZodInput<S> extracts the inferred TypeScript type from a Zod schema. Together they give you a fully typed MCP server with a single source of truth in the tool definitions array — no manual casts, no drifting dispatch tables.

ToolArgs<T, Name> — extracting argument types from the tools tuple

The most common manual cast in an MCP handler is args as { path: string } or similar. ToolArgs eliminates this by extracting the inferred Zod input type for a specific tool by name. It combines PickTool and InferZodInput into a single ergonomic lookup:

// types/tool-args.ts
import { z } from 'zod';

// Base shape for every tool definition in the tuple
interface ToolDef {
  name: string;
  description: string;
  inputSchema: z.ZodTypeAny;
}

// Extract the Zod-inferred input type from a Zod schema
type InferZodInput<S> = S extends z.ZodType ? z.infer<S> : never;

// Pick one tool from the tuple by its name
type PickTool<T extends readonly ToolDef[], Name extends T[number]['name']> =
  Extract<T[number], { name: Name }>;

// Combine: extract the args type for a named tool
type ToolArgs<T extends readonly ToolDef[], Name extends T[number]['name']> =
  InferZodInput<PickTool<T, Name>['inputSchema']>;

// --- Usage ---

const TOOLS = [
  {
    name: 'files_read' as const,
    description: 'Read a file from the workspace',
    inputSchema: z.object({ path: z.string(), encoding: z.enum(['utf8', 'base64']).default('utf8') }),
  },
  {
    name: 'db_query' as const,
    description: 'Execute a read-only SQL query',
    inputSchema: z.object({ sql: z.string(), params: z.array(z.unknown()).default([]) }),
  },
] as const satisfies readonly ToolDef[];

type FilesReadArgs = ToolArgs<typeof TOOLS, 'files_read'>;
// { path: string; encoding: "utf8" | "base64" }

type DbQueryArgs = ToolArgs<typeof TOOLS, 'db_query'>;
// { sql: string; params: unknown[] }

// Handler receives fully typed args — no cast needed
async function handleFilesRead(args: FilesReadArgs): Promise<ToolResult> {
  // args.path is string, args.encoding is 'utf8' | 'base64'
  const content = await fs.readFile(args.path, args.encoding);
  return { content: [{ type: 'text', text: content }] };
}

ToolResult — typing the return value of tool handlers

MCP tool handlers return a content array plus an optional isError flag. Defining this as a named type makes all handlers consistent and prevents subtle bugs like returning a plain string instead of a content block:

// types/tool-result.ts

// A single content block — text, image, or embedded resource
type ContentBlock =
  | { type: 'text';     text: string }
  | { type: 'image';    data: string; mimeType: string }
  | { type: 'resource'; resource: { uri: string; text?: string; blob?: string; mimeType?: string } };

// The return type of every MCP tool handler
interface ToolResult {
  content: [ContentBlock, ...ContentBlock[]];  // at least one block required
  isError?: boolean;
}

// Helper constructors so handlers don't build blocks manually
export const ok = (text: string): ToolResult => ({
  content: [{ type: 'text', text }],
});

export const err = (message: string): ToolResult => ({
  isError: true,
  content: [{ type: 'text', text: message }],
});

export const json = (data: unknown): ToolResult => ({
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
});

// Handlers declare their return type explicitly — the compiler
// enforces that at least one content block is always returned:
async function handleDbQuery(args: DbQueryArgs): Promise<ToolResult> {
  try {
    const rows = await db.all(args.sql, args.params);
    return json(rows);
  } catch (e) {
    return err(e instanceof Error ? e.message : String(e));
  }
}

PickTool<T, Name> — extracting a single tool definition

PickTool uses Extract<> to narrow the tuple's member type to the single entry whose name field matches. This is the primitive that ToolArgs builds on, but it is also useful independently when you need access to a specific tool's full definition — for example, to extract the description or the schema for documentation generation:

// types/pick-tool.ts

type PickTool<
  T extends readonly ToolDef[],
  Name extends T[number]['name']
> = Extract<T[number], { name: Name }>;

// The full definition for 'files_read':
type FilesReadDef = PickTool<typeof TOOLS, 'files_read'>;
// {
//   name: 'files_read';
//   description: 'Read a file from the workspace';
//   inputSchema: z.ZodObject<{ path: z.ZodString; encoding: z.ZodDefault<z.ZodEnum<...>> }>;
// }

// Access specific fields:
type FilesReadSchema = PickTool<typeof TOOLS, 'files_read'>['inputSchema'];
// z.ZodObject<{ path: z.ZodString; encoding: z.ZodDefault<z.ZodEnum<...>> }>

// Generate JSON Schema for a specific tool (useful for documentation):
function getToolJsonSchema<Name extends typeof TOOLS[number]['name']>(
  name: Name
): Record<string, unknown> {
  const tool = TOOLS.find(t => t.name === name)!;
  // zodToJsonSchema is from the 'zod-to-json-schema' package
  return zodToJsonSchema(tool.inputSchema) as Record<string, unknown>;
}

ToolHandlerMap<T> — the complete mapped handler type

ToolHandlerMap produces a mapped type where every key is a tool name and every value is the correctly typed handler function for that tool. Assigning a handler map object to this type gives a compile error if any handler is missing, has the wrong argument type, or returns the wrong shape:

// types/handler-map.ts

type ToolHandlerMap<T extends readonly ToolDef[]> = {
  [K in T[number]['name']]: (args: ToolArgs<T, K>) => Promise<ToolResult>;
};

// The compiler now enforces that every tool has a handler
// and that each handler accepts exactly the right args type:
const handlers: ToolHandlerMap<typeof TOOLS> = {
  files_read: async (args) => {
    // args is { path: string; encoding: 'utf8' | 'base64' } — no cast
    const content = await fs.readFile(args.path, args.encoding);
    return ok(content);
  },
  db_query: async (args) => {
    // args is { sql: string; params: unknown[] } — no cast
    const rows = await db.all(args.sql, args.params);
    return json(rows);
  },
  // Missing a handler? Compile error: Property 'X' is missing
  // Wrong args? Compile error: Argument of type 'Y' is not assignable
};

// Dispatch: look up the handler by name and call it
async function dispatch(
  name: typeof TOOLS[number]['name'],
  rawArgs: unknown
): Promise<ToolResult> {
  const tool = TOOLS.find(t => t.name === name);
  if (!tool) return err(`Unknown tool: ${name}`);
  const parsed = tool.inputSchema.safeParse(rawArgs);
  if (!parsed.success) return err(parsed.error.message);
  return (handlers[name] as (args: unknown) => Promise<ToolResult>)(parsed.data);
}

InferZodInput<S> — extracting the TypeScript type from a Zod schema

InferZodInput is a thin wrapper around z.infer that adds a never branch for non-Zod types. This makes it safe to use in generic contexts where the schema type is not statically known to be a z.ZodType:

// types/infer-zod.ts

type InferZodInput<S> = S extends z.ZodType ? z.infer<S> : never;

// Works with any Zod schema:
const userSchema = z.object({
  id:    z.string().uuid(),
  email: z.string().email(),
  role:  z.enum(['admin', 'user', 'readonly']),
});

type User = InferZodInput<typeof userSchema>;
// { id: string; email: string; role: 'admin' | 'user' | 'readonly' }

// Safe in generic constraints — non-Zod types produce never rather than errors:
type MaybeArgs<S> = InferZodInput<S>;
type A = MaybeArgs<typeof userSchema>;  // User
type B = MaybeArgs<string>;             // never (not a ZodType)

// In a generic factory, this lets you constrain schema-accepting parameters:
function createValidator<S extends z.ZodTypeAny>(
  schema: S
): (input: unknown) => InferZodInput<S> {
  return (input) => schema.parse(input) as InferZodInput<S>;
}

const validateUser = createValidator(userSchema);
const user = validateUser({ id: 'abc...', email: 'a@b.com', role: 'admin' });
// user is typed as User — no cast

Complete example: createServer<T>() factory

Combining all five utility types into a createServer factory produces a server where the tool definitions array is the single source of truth. Pass the tools tuple and a matching handler map; the compiler enforces correctness at both points:

// server/factory.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { zodToJsonSchema } from 'zod-to-json-schema';

function createServer<T extends readonly ToolDef[]>(config: {
  name:     string;
  version:  string;
  tools:    T;
  handlers: ToolHandlerMap<T>;
}): McpServer {
  const server = new McpServer({ name: config.name, version: config.version });

  // Register every tool from the definitions tuple
  for (const tool of config.tools) {
    server.tool(
      tool.name,
      tool.description,
      zodToJsonSchema(tool.inputSchema) as Record<string, unknown>,
      async (request) => {
        const parsed = tool.inputSchema.safeParse(request.params?.arguments);
        if (!parsed.success) {
          return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
        }
        const handler = config.handlers[tool.name as T[number]['name']];
        return (handler as (args: unknown) => Promise<ToolResult>)(parsed.data);
      }
    );
  }

  return server;
}

// Instantiate — the compiler checks that handlers matches the tools tuple:
const server = createServer({
  name:    'workspace-server',
  version: '1.0.0',
  tools:   TOOLS,
  handlers: {
    files_read: async (args) => ok(await fs.readFile(args.path, args.encoding)),
    db_query:   async (args) => json(await db.all(args.sql, args.params)),
  },
});

// Connect AliveMCP-compatible health probe:
server.tool('health_check', 'Returns server health status', {}, async () =>
  ok(JSON.stringify({ status: 'ok', ts: Date.now() }))
);

const transport = new StdioServerTransport();
await server.connect(transport);

The health probe registered at the bottom is what AliveMCP calls to verify the server is alive and responding. Because createServer returns a typed McpServer, adding the probe after construction is still type-checked by the SDK's own types.

Testing utility: compile-time type assertions with TypeCheck<T, Expected>

Unit tests verify runtime behavior; compile-time assertions verify that your utility types produce the shapes you expect. A TypeCheck helper causes a compile error if a type does not match an expected type — useful in tests that would otherwise silently pass even if a type changed:

// types/type-check.ts

// Fails to compile if T and Expected are not mutually assignable
type TypeCheck<T, Expected> =
  T extends Expected
    ? Expected extends T
      ? true
      : never   // Expected is not assignable to T
    : never;    // T is not assignable to Expected

// Usage in a test file (no runtime assertions needed):
import { expectType } from './type-check';

// These "tests" are purely compile-time — they produce no runtime code:
type _1 = TypeCheck<FilesReadArgs, { path: string; encoding: 'utf8' | 'base64' }>;
// true — types match

type _2 = TypeCheck<ToolArgs<typeof TOOLS, 'db_query'>, { sql: string; params: unknown[] }>;
// true

// If a type changes, the TypeCheck produces 'never':
type _3 = TypeCheck<FilesReadArgs, { path: number }>;
// never — compile error surfaces here, not at some distant callsite

// A runtime helper for use in tests that want a "typechecks" assertion:
function assertType<T>(_: T): void {}
// assertType<TypeCheck<FilesReadArgs, { path: string; encoding: 'utf8' | 'base64' }>>(true);
// Compile error if TypeCheck resolves to 'never'

Related questions

Do I need all five utility types, or can I use a subset?

You can use any subset. ToolResult and InferZodInput are independently useful and have no dependencies on each other. PickTool is the foundation for both ToolArgs and ToolHandlerMap — if you want those two, you need PickTool. For small servers with two or three tools, defining explicit handler types inline is often simpler than setting up the full utility type infrastructure. The five-type approach pays off when the tool count exceeds about six, when multiple developers contribute handlers, or when handlers are split across multiple files.

Can I use these utility types with runtime-registered tools?

Only partially. Utility types like ToolHandlerMap operate at compile time on a statically known tuple. If tools are registered at runtime from a database or configuration file, the tuple is not statically known and the mapped types cannot be computed. In that case, use ToolResult and InferZodInput where possible (for the schemas you do know statically), and fall back to a Map<string, (args: unknown) => Promise<ToolResult>> for the dynamic portion. Validate dynamic tool names at the boundary with Zod before dispatching.

How do I handle union return types from tool handlers?

If some handlers return different content types — text in the success case, an image in another — model that as a union of ContentBlock shapes inside the content array rather than as a union of ToolResult. The content field in ToolResult is already typed as an array of ContentBlock which is itself a discriminated union on type. Callers narrow the content block type by checking block.type === 'image'. Avoid making ToolResult itself a union — the MCP protocol expects a uniform envelope shape; variation lives inside the content array.

Do these patterns apply to Python MCP servers?

The specific utility types described here are TypeScript-only — Python has no equivalent of template literal types or mapped types. However, Python MCP servers can achieve similar safety with Pydantic models: define a BaseModel per tool for argument validation (equivalent to Zod schemas), use Python's TypeVar and Generic for reusable handler patterns, and use Literal types from typing for closed string sets. The architectural principle — single source of truth in the tool definitions, derived types everywhere else — applies regardless of language.

Further reading