Guide · TypeScript

MCP server type guards

The MCP SDK delivers tool arguments as unknown — the JSON-decoded value from the protocol layer. TypeScript cannot verify the shape of an unknown value at compile time; you must narrow it at runtime before accessing properties. Type guards are the safe, idiomatic way to narrow unknown tool arguments to a typed interface without as-casts that silently hide mismatches.

TL;DR

Use z.safeParse() for all tool argument narrowing — it validates the shape, returns typed data, and produces a structured error message for the LLM when arguments are wrong. Avoid as ToolArgs casts. For multi-tool dispatch, combine discriminated union narrowing with an exhaustive switch so the compiler tells you when a new tool name is unhandled. Pair this with AliveMCP to surface tool call error rates in production.

Why arguments is unknown

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 deserializes this JSON using JSON.parse and hands the result to your handler. Because JSON.parse returns any, and because the protocol itself imposes no schema on argument values beyond "it is a JSON object", the SDK types the arguments as Record<string, unknown> at best — and in practice, handler signatures expose them as unknown.

The TypeScript compiler cannot inspect JSON at compile time. It does not know whether the caller passed { "path": "/etc/hosts" } or { "path": 42 } or an empty object. If you write args.path without narrowing, TypeScript raises an error on unknown. If you silence that error with as FilesReadArgs, TypeScript trusts you — and you will get a runtime exception when the LLM passes an unexpected shape. Type guards move that check to runtime, where you can catch it, log it, and return a helpful error response instead of throwing.

The SDK's setRequestHandler for CallToolRequestSchema gives you the raw request. The params.arguments field is typed as Record<string, unknown> | undefined:

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

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

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  // args is: Record<string, unknown> | undefined
  // Accessing args.path here would be a TypeScript error on unknown
  // and a runtime hazard even if you cast it

  // ... you must narrow before use
});

The Zod safeParse pattern

Zod is the standard validation library for MCP server argument narrowing. Define a schema that mirrors your tool's inputSchema, then call z.safeParse(args) at the top of your handler. When parsing succeeds, the data field is fully typed. When parsing fails, the error.format() output tells the LLM exactly which fields were wrong.

import { z } from "zod";

// Define the schema once — reuse for inputSchema and for validation
const FilesReadArgsSchema = z.object({
  path: z.string().min(1, "path must be a non-empty string"),
  encoding: z.enum(["utf8", "base64"]).optional().default("utf8"),
  max_bytes: z.number().int().positive().optional(),
});

type FilesReadArgs = z.infer<typeof FilesReadArgsSchema>;

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

  if (name === "files_read") {
    const parsed = FilesReadArgsSchema.safeParse(rawArgs ?? {});

    if (!parsed.success) {
      const formatted = parsed.error.format();
      return {
        isError: true,
        content: [{
          type: "text",
          text: JSON.stringify({
            error: "invalid_arguments",
            tool: "files_read",
            details: formatted,
          }),
        }],
      };
    }

    // parsed.data is FilesReadArgs — fully typed, safe to access
    const { path, encoding, max_bytes } = parsed.data;
    const content = await readFile(path, encoding, max_bytes);
    return { content: [{ type: "text", text: content }] };
  }

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

The key advantage of safeParse over parse is that it does not throw. A thrown exception inside a setRequestHandler can crash the request handler or surface as an opaque protocol error. With safeParse, validation failure is a value — you control the error response and can include enough detail for the LLM to self-correct its next call.

Custom type predicate functions

When you cannot use Zod — perhaps you are in a zero-dependency environment or need very fine-grained control — write type predicate functions with the value is T return type syntax. A type predicate is a function that returns boolean at runtime but signals to TypeScript that if it returns true, the argument is narrowed to type T.

interface FilesReadArgs {
  path: string;
  encoding?: "utf8" | "base64";
  max_bytes?: number;
}

function isFilesReadArgs(v: unknown): v is FilesReadArgs {
  if (typeof v !== "object" || v === null) return false;
  const obj = v as Record<string, unknown>;

  if (typeof obj["path"] !== "string" || obj["path"].length === 0) return false;

  if (obj["encoding"] !== undefined) {
    if (obj["encoding"] !== "utf8" && obj["encoding"] !== "base64") return false;
  }

  if (obj["max_bytes"] !== undefined) {
    if (typeof obj["max_bytes"] !== "number" || !Number.isInteger(obj["max_bytes"]) || obj["max_bytes"] <= 0) return false;
  }

  return true;
}

// Usage in handler:
if (name === "files_read") {
  if (!isFilesReadArgs(rawArgs)) {
    return {
      isError: true,
      content: [{ type: "text", text: "files_read requires: path (string), optional encoding ('utf8'|'base64'), optional max_bytes (positive int)" }],
    };
  }
  // rawArgs is now FilesReadArgs
  const { path, encoding = "utf8" } = rawArgs;
}

Type predicates are more verbose than Zod schemas but have zero runtime overhead from schema compilation. They are also easier to make conditional — you can short-circuit checks based on a type discriminant field before checking the rest of the shape. For large MCP servers with dozens of tools, consider a hybrid: Zod for complex nested schemas, hand-written predicates for simple flat argument objects.

Discriminated union narrowing for multi-tool dispatch

When one setRequestHandler handles multiple tools, you need to narrow both the tool name and the argument shape. Discriminated unions on the tool name let TypeScript help you in the narrowing branches:

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

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

// Union type for all argument shapes
type ToolArgs =
  | { tool: "files_read"; args: FilesReadArgs }
  | { tool: "search"; args: z.infer<typeof SearchArgsSchema> }
  | { tool: "files_write"; args: z.infer<typeof WriteArgsSchema> };

// Runtime narrowing helper
function parseToolArgs(name: string, raw: unknown): ToolArgs | { error: string } {
  if (name === "files_read") {
    const r = FilesReadArgsSchema.safeParse(raw ?? {});
    if (!r.success) return { error: r.error.message };
    return { tool: "files_read", args: r.data };
  }
  if (name === "search") {
    const r = SearchArgsSchema.safeParse(raw ?? {});
    if (!r.success) return { error: r.error.message };
    return { tool: "search", args: r.data };
  }
  if (name === "files_write") {
    const r = WriteArgsSchema.safeParse(raw ?? {});
    if (!r.success) return { error: r.error.message };
    return { tool: "files_write", args: r.data };
  }
  return { error: `Unknown tool: ${name}` };
}

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

  if ("error" in parsed) {
    return { isError: true, content: [{ type: "text", text: parsed.error }] };
  }

  // 'query' in parsed.args — TypeScript now knows which union member
  if (parsed.tool === "search") {
    const { query, limit } = parsed.args; // typed as SearchArgs
    return runSearch(query, limit);
  }
  // ... dispatch remaining tools
});

The 'query' in args pattern works for inline narrowing when tool argument shapes have unique discriminant properties. Prefer the explicit parsed.tool === "search" check over structural 'query' in args checks — it is clearer and avoids false positives when two tools share a property name.

Exhaustive switch with never

As your MCP server grows, unhandled tool names become a risk — you add a tool to the tools list but forget to add its handler branch. An exhaustive switch with an assertNever helper turns that oversight into a compile-time error:

// utils/assert-never.ts
export function assertNever(x: never, message?: string): never {
  throw new Error(message ?? `Unhandled case: ${JSON.stringify(x)}`);
}

// In your handler — ToolName is a string literal union of all tool names
type ToolName = "files_read" | "search" | "files_write";

function isToolName(name: string): name is ToolName {
  return ["files_read", "search", "files_write"].includes(name);
}

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

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

  const parsed = parseToolArgs(name, rawArgs);
  if ("error" in parsed) {
    return { isError: true, content: [{ type: "text", text: parsed.error }] };
  }

  // Exhaustive switch over the narrowed ToolArgs union
  switch (parsed.tool) {
    case "files_read":
      return handleFilesRead(parsed.args);
    case "search":
      return handleSearch(parsed.args);
    case "files_write":
      return handleFilesWrite(parsed.args);
    default:
      // If you add a new tool to ToolName but not to this switch,
      // TypeScript reports: Argument of type 'string' is not assignable
      // to parameter of type 'never'
      return assertNever(parsed.tool, `Unhandled tool: ${(parsed as any).tool}`);
  }
});

Add assertNever to the default branch of every tool-dispatch switch. When you later add "files_delete" to ToolName and forget to add its case, the TypeScript compiler immediately reports the error — before the code ships and before an LLM discovers the gap at runtime.

Error messages for LLMs

When argument validation fails, the content of the isError response is the LLM's primary signal for how to fix its next call. A vague error like "invalid arguments" forces the LLM to guess. A structured error with the field path and the constraint that failed allows the LLM to self-correct reliably:

// Format a Zod error as a structured, LLM-readable response
function makeZodErrorResponse(
  toolName: string,
  error: z.ZodError
): { isError: true; content: Array<{ type: "text"; text: string }> } {
  const issues = error.issues.map((issue) => ({
    path: issue.path.join(".") || "(root)",
    code: issue.code,
    message: issue.message,
    // Include received value type when it helps disambiguation
    received: "received" in issue ? issue.received : undefined,
    expected: "expected" in issue ? issue.expected : undefined,
  }));

  return {
    isError: true,
    content: [{
      type: "text",
      text: JSON.stringify({
        error: "invalid_arguments",
        tool: toolName,
        message: `${toolName} received invalid arguments. Fix the fields listed in 'issues' and retry.`,
        issues,
      }, null, 2),
    }],
  };
}

// Usage
const parsed = FilesReadArgsSchema.safeParse(rawArgs ?? {});
if (!parsed.success) {
  return makeZodErrorResponse("files_read", parsed.error);
}
/* Returns to the LLM:
{
  "error": "invalid_arguments",
  "tool": "files_read",
  "message": "files_read received invalid arguments. Fix the fields listed in 'issues' and retry.",
  "issues": [
    { "path": "path", "code": "invalid_type", "message": "Expected string, received number",
      "received": "number", "expected": "string" }
  ]
}
*/

JSON-formatted error content is more reliable than prose. LLMs parse structured JSON better than unstructured English error messages in tool responses. Include the tool name in the error so the LLM can correlate the response to its call when multiple tools are in flight. Log the same structured object server-side — it becomes the signal you monitor in AliveMCP to detect which tools have elevated argument validation failure rates.

Error format LLM self-correction rate Notes
"invalid arguments" Low No signal for what to fix
"path must be a string" Medium Field named, but no structure
JSON with issues[].path and issues[].message High Machine-parseable, LLM can iterate
JSON with issues[].received and issues[].expected Highest Diff between what was sent and what was expected

Related questions

Why not use as ToolArgs casts instead of type guards?

An as-cast is a compile-time lie — it tells TypeScript to trust you, but it performs no runtime check. If the LLM sends { "path": 42 } and your handler casts with args as FilesReadArgs, TypeScript compiles happily and your handler crashes at runtime when it calls path.startsWith("/") on a number. Type guards and safeParse catch that mismatch before it propagates. Reserve as-casts for cases where you have already performed a runtime check and TypeScript cannot infer the narrowing itself.

Is Zod safeParse slow enough to matter in an MCP handler?

No. Zod schema compilation (calling z.object() etc.) has a one-time cost at module load. Parsing a typical flat MCP argument object with 3–5 fields takes under 100 microseconds. MCP tool calls are already network-bound; validation overhead is negligible. If you are handling thousands of tool calls per second in a hot path, benchmark first — but for typical MCP workloads, Zod adds no meaningful latency.

How do I handle optional fields that the LLM might omit entirely?

Use z.optional() or chain .default() in your Zod schema. With .default("utf8"), Zod populates the field in parsed.data even when the LLM omits it — so your handler code can always access the field without optional-chaining. For nested optional objects, use z.object({...}).optional() and add a .default({}) only if an empty object is a valid default. Never use a type predicate that returns true for missing required fields — distinguish between optional and required at the schema level.

What about deeply nested argument objects?

Zod handles nested objects naturally with z.object({ inner: z.object({ ... }) }). The error.format() output includes the full dot-separated path to the failing field, so LLM error messages remain useful even for 3-level-deep objects. Hand-written type predicates for nested objects grow verbose — consider a helper that checks each level recursively, or switch to Zod for anything deeper than one level of nesting. For arrays of objects, use z.array(z.object({...})); Zod reports the array index in the error path ("items.2.path").

Further reading