Guide · TypeScript

MCP server Zod validation

The MCP SDK accepts any JSON object that satisfies the declared inputSchema — but an LLM can produce valid JSON with structurally-correct but semantically-wrong values (a negative page size, an empty string where an ID is required, a URL without a scheme). TypeScript types are erased at runtime and provide no protection after the tool call arrives. Zod closes the gap: define the schema once, derive the TypeScript type from it with z.infer, convert it to the JSON Schema the MCP SDK needs with zodToJsonSchema, and validate every incoming tool call with safeParse so validation failures become structured isError: true responses the LLM can interpret and retry.

TL;DR

Install zod and zod-to-json-schema. Define schemas with z.object(), convert with zodToJsonSchema(schema) for the inputSchema, derive types with z.infer<typeof schema>. In your handler, call schema.safeParse(args) — if !result.success, return { content: [{ type: 'text', text: result.error.message }], isError: true } so the LLM sees a recovery path rather than a protocol error. Never call schema.parse() inside a tool handler — a thrown ZodError produces a JSON-RPC -32603 the LLM cannot recover from.

Why Zod instead of manual validation

Without Zod, an MCP tool handler has three separate representations of its inputs: the inputSchema JSON object in ListToolsRequest, the TypeScript interface used inside the handler, and the manual validation checks scattered through the function body. All three drift independently. Add a field to the interface and forget the JSON Schema, and MCP clients generate wrong forms. Fix the JSON Schema and forget the TypeScript, and the handler has the wrong type. Zod collapses all three into one source of truth.

ConcernWithout ZodWith Zod
inputSchema for MCPHand-written JSON Schema objectzodToJsonSchema(schema)
TypeScript typeSeparate interface, drifts from schemaz.infer<typeof schema> — same source
Runtime validationManual if/throw blocksschema.safeParse(args)
Error messagesAd-hoc stringsStructured Zod error with path + message
Schema evolutionUpdate three placesUpdate schema once

Installation

npm install zod zod-to-json-schema

zod-to-json-schema converts a Zod schema to a JSON Schema 7 object compatible with the MCP SDK's inputSchema field. It handles nested objects, arrays, enums, optional fields, and Zod's .describe() annotations, which become JSON Schema description strings — the MCP Inspector and LLM clients display these as field hints.

Defining and wiring a Zod schema

The pattern is: define the Zod schema at module level, call zodToJsonSchema in the ListToolsRequest handler, derive the TypeScript type with z.infer, and validate with safeParse in the CallToolRequest handler.

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';

// 1. Define schema once
const SearchUsersSchema = z.object({
  query:    z.string().min(1).describe('Search term — name, email, or partial match'),
  page:     z.number().int().positive().default(1).describe('Page number, starting at 1'),
  pageSize: z.number().int().min(1).max(100).default(20).describe('Results per page, 1–100'),
});

// 2. Derive the TypeScript type — no separate interface needed
type SearchUsersInput = z.infer<typeof SearchUsersSchema>;

const server = new Server(
  { name: 'user-service-mcp', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'search_users',
      description: 'Search for users by name or email.',
      // 3. Convert to JSON Schema for the MCP inputSchema
      inputSchema: zodToJsonSchema(SearchUsersSchema) as Record<string, unknown>,
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === 'search_users') {
    // 4. Validate at the handler boundary
    const result = SearchUsersSchema.safeParse(request.params.arguments);
    if (!result.success) {
      return {
        content: [{ type: 'text', text: `Invalid arguments: ${result.error.message}` }],
        isError: true,
      };
    }

    const { query, page, pageSize }: SearchUsersInput = result.data;
    // result.data is fully typed — no type assertions needed
    const users = await db.searchUsers(query, { page, pageSize });
    return {
      content: [{ type: 'text', text: JSON.stringify(users) }],
    };
  }
  throw new Error(`Unknown tool: ${request.params.name}`);
});

safeParse vs parse — why this matters for MCP

Zod has two validation methods: parse() throws a ZodError on invalid input; safeParse() returns a discriminated union ({ success: true, data: T } | { success: false, error: ZodError }). Inside an MCP tool handler, throwing any error produces a JSON-RPC -32603 Internal error response — this is a protocol-level error that MCP clients treat as fatal and log to the protocol console, not as a recoverable tool failure. The LLM cannot see the error message and cannot retry with corrected arguments.

Returning { content: [...], isError: true } is the correct pattern for user-facing or LLM-recoverable failures. The LLM client receives the content array, can read the error description, and can retry the tool with corrected arguments. This distinction is explained in detail in MCP server error handling.

Validation approachOn invalid inputLLM seesLLM can retry?
schema.parse(args)Throws ZodError → JSON-RPC -32603Protocol error in client logNo
schema.safeParse(args) + isError returnReturns isError: true responseError message in content arrayYes

Writing LLM-friendly error messages

The default ZodError.message is human-readable but verbose. For LLM clients, a more useful pattern is to format the Zod issues as a compact, actionable list. Zod's error.issues array has each issue's path, code, and message.

function formatZodError(error: z.ZodError): string {
  return error.issues
    .map(issue => `${issue.path.join('.') || 'input'}: ${issue.message}`)
    .join('; ');
}

// In the handler:
if (!result.success) {
  return {
    content: [{
      type: 'text',
      text: `search_users validation failed — ${formatZodError(result.error)}`,
    }],
    isError: true,
  };
}

This produces messages like: search_users validation failed — page: Expected positive number; pageSize: Number must be less than or equal to 100. The LLM can parse the field name and constraint and retry with a corrected value.

Zod's .describe() method adds a description to the JSON Schema that MCP clients and the MCP Inspector display as field hints. Write these descriptions as LLM instructions, not human documentation: "Page number starting at 1, not 0" is more useful than "Page number" when the LLM is choosing values.

Handling multiple tools with a schema registry

For servers with many tools, define schemas in a registry and loop over them for both ListTools and CallTool handling. This eliminates boilerplate and ensures every tool has validation.

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

// Schema registry — one entry per tool
const TOOL_SCHEMAS = {
  search_users: z.object({
    query:    z.string().min(1).describe('Search term'),
    page:     z.number().int().positive().default(1),
    pageSize: z.number().int().min(1).max(100).default(20),
  }),
  get_user: z.object({
    userId: z.string().uuid().describe('UUID of the user to retrieve'),
  }),
  delete_user: z.object({
    userId:  z.string().uuid().describe('UUID of the user to delete'),
    confirm: z.literal(true).describe('Must be true to confirm deletion'),
  }),
} as const;

type ToolName = keyof typeof TOOL_SCHEMAS;
type ToolInput<T extends ToolName> = z.infer<(typeof TOOL_SCHEMAS)[T]>;

// ListTools — built from registry
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: (Object.entries(TOOL_SCHEMAS) as [ToolName, z.ZodTypeAny][]).map(
    ([name, schema]) => ({
      name,
      description: TOOL_DESCRIPTIONS[name],
      inputSchema: zodToJsonSchema(schema) as Record<string, unknown>,
    })
  ),
}));

// CallTool — validate with the matching schema from registry
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const name = request.params.name as ToolName;
  const schema = TOOL_SCHEMAS[name];
  if (!schema) throw new Error(`Unknown tool: ${name}`);

  const parsed = schema.safeParse(request.params.arguments);
  if (!parsed.success) {
    return {
      content: [{ type: 'text', text: formatZodError(parsed.error) }],
      isError: true,
    };
  }

  return handlers[name](parsed.data as ToolInput<typeof name>);
});

Discriminated union inputs

Some tools accept inputs in multiple valid shapes — for example, a search tool that accepts either a user ID or an email address, but not both. Zod's z.discriminatedUnion() models this precisely and generates a JSON Schema oneOf that MCP clients can validate against.

const LookupUserSchema = z.discriminatedUnion('by', [
  z.object({
    by:     z.literal('id'),
    userId: z.string().uuid().describe('UUID of the user'),
  }),
  z.object({
    by:    z.literal('email'),
    email: z.string().email().describe('Email address of the user'),
  }),
]);

// Narrows the type based on the 'by' discriminant
type LookupUserInput = z.infer<typeof LookupUserSchema>;

// In the handler:
const parsed = LookupUserSchema.safeParse(args);
if (!parsed.success) { /* ... */ }

if (parsed.data.by === 'id') {
  // TypeScript knows parsed.data.userId exists here
  return db.getUserById(parsed.data.userId);
} else {
  // TypeScript knows parsed.data.email exists here
  return db.getUserByEmail(parsed.data.email);
}

Zod schema patterns for common MCP inputs

Input typeZod schemaNotes
Required string IDz.string().uuid()Use .cuid() or .ulid() for other ID formats
Paginationz.number().int().positive().default(1).default() sets the value used when the field is absent
Enum / categoryz.enum(['active', 'archived', 'deleted'])Zod enums produce JSON Schema enum arrays; LLM clients show a dropdown
Optional with fallbackz.string().optional().default('asc')Field may be absent; uses default if so
Non-empty stringz.string().min(1)Prevents empty-string confusion
Date/time stringz.string().datetime()Validates ISO 8601; use .describe() to clarify timezone expectations
Bounded arrayz.array(z.string()).min(1).max(50)Prevents unbounded batch operations
Confirmation flagz.literal(true)Forces the LLM to explicitly confirm destructive operations

Testing Zod validation

Because Zod validation runs inside the tool handler, unit tests with InMemoryTransport exercise the full validation path. Write one test for the happy path, one for each validation constraint, and one for a completely missing required field.

import { describe, it, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from './server.js';

describe('search_users validation', () => {
  async function callTool(args: unknown) {
    const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
    const server = createServer(fakeDeps);
    await server.connect(serverTransport);
    const client = new Client({ name: 'test', version: '1.0.0' }, { capabilities: {} });
    await client.connect(clientTransport);
    const result = await client.callTool({ name: 'search_users', arguments: args as Record<string, unknown> });
    await client.close();
    return result;
  }

  it('returns users on valid input', async () => {
    const result = await callTool({ query: 'alice', page: 1 });
    expect(result.isError).toBeFalsy();
  });

  it('returns isError on empty query string', async () => {
    const result = await callTool({ query: '' });
    expect(result.isError).toBe(true);
    expect((result.content[0] as { text: string }).text).toMatch(/query/);
  });

  it('returns isError on negative page', async () => {
    const result = await callTool({ query: 'alice', page: -1 });
    expect(result.isError).toBe(true);
    expect((result.content[0] as { text: string }).text).toMatch(/page/);
  });

  it('returns isError when required field is missing', async () => {
    const result = await callTool({});
    expect(result.isError).toBe(true);
  });
});

See MCP server test coverage for coverage targets by file type and how to ensure validation branches are included in coverage reports.

What Zod validation catches vs. what AliveMCP catches

Zod validates the structure and constraints of tool inputs at the moment a tool call arrives. It catches LLM hallucinations (wrong types), missing required fields, out-of-range values, and invalid formats. What Zod cannot detect is whether your deployed server is reachable, whether the MCP initialize handshake completes over the network, or whether the server started correctly after a deployment. Those are runtime, infrastructure-level failures invisible to input validation. AliveMCP probes the live protocol endpoint every 60 seconds so you know within a minute if a deployment broke the server before any LLM client encounters it.

Related questions

Does zodToJsonSchema produce a schema the MCP SDK accepts?

Yes. zodToJsonSchema(schema) returns a JSON Schema Draft 7 object, which the MCP SDK accepts as inputSchema. The SDK passes this schema through to MCP clients that use it for validation and UI generation. One caveat: MCP's inputSchema expects the root to be an object schema ({ type: 'object', properties: {...} }). Zod's z.object() produces this naturally. If you pass a non-object Zod schema (z.string(), z.array()), wrap it in z.object() first.

Can I use Valibot or Yup instead of Zod?

Yes. Any schema library that can output a JSON Schema 7 object can replace Zod. Valibot (toJsonSchema from @valibot/to-json-schema) and Yup (yup-to-json-schema) both work. The core pattern — derive TypeScript types from the schema, convert to JSON Schema for the MCP inputSchema, validate with safeParse at the handler boundary — is the same regardless of library. Zod is the most common choice in the MCP SDK ecosystem because the official example servers use it.

Should I validate tool outputs with Zod too?

It's optional but useful for complex structured outputs. The MCP protocol does not declare an output schema, so LLM clients accept any content array. However, defining an output schema with Zod and calling outputSchema.parse(result) before returning catches bugs where a tool returns wrong structure (a missing field, a wrong type). Unlike input validation, throwing here is acceptable — an unexpected output structure is a server bug, not a recoverable LLM error. Use Zod's parse() for output validation so bugs surface immediately during development.

How do I handle Zod defaults with the MCP protocol?

Zod's .default() fills in missing fields during safeParse. The LLM client may omit optional fields; Zod fills them in. However, the JSON Schema produced by zodToJsonSchema includes the default in the schema's default key — LLM clients that read the schema may pre-fill the default value before sending the tool call, making it explicit. Both cases work correctly with safeParse on the server side.

Further reading