Skip to main content
RapidDev - Software Development Agency
mcp-tutorial

How to add Zod input validation to MCP tools

Use Zod schemas in your MCP tool's inputSchema to validate parameters before your handler runs. Define string constraints, number ranges, enums, optional fields, arrays, and nested objects to ensure the AI always sends correctly structured data. In Python, use type hints and Pydantic models for equivalent validation.

What you'll learn

  • How to use Zod string, number, enum, and array validators in MCP tools
  • How to define optional and default parameters
  • How to validate nested objects and complex input shapes
  • How to handle validation errors gracefully in tool handlers
  • How to use Pydantic models for Python MCP tool validation
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read20-30 minMCP TypeScript SDK 1.x, MCP Python SDK 1.x, Zod 3.xMarch 2026RapidDev Engineering Team
TL;DR

Use Zod schemas in your MCP tool's inputSchema to validate parameters before your handler runs. Define string constraints, number ranges, enums, optional fields, arrays, and nested objects to ensure the AI always sends correctly structured data. In Python, use type hints and Pydantic models for equivalent validation.

Adding Input Validation to MCP Tools

When an AI model calls your MCP tool, it generates the input arguments based on your schema description. Proper validation ensures your handler receives correctly typed, bounded, and formatted data — even when the AI makes mistakes. The MCP TypeScript SDK uses Zod for schema definition, which provides both runtime validation and TypeScript type inference.

This tutorial covers practical Zod patterns for MCP tools: string constraints (min/max length, regex), number ranges, enums, optional fields with defaults, arrays, and nested objects. Each pattern includes the Zod schema and matching Python type hint equivalent.

Prerequisites

  • MCP TypeScript SDK installed with Zod (included as dependency)
  • A working MCP server with at least one registered tool
  • Basic familiarity with TypeScript types and Zod syntax
  • For Python: MCP Python SDK installed with Pydantic

Step-by-step guide

1

Add string constraints to tool parameters

Use Zod's string methods to constrain text inputs. Add .min() and .max() for length limits, .regex() for pattern matching, .url() for URL validation, and .email() for email addresses. Always add .describe() so the AI knows what format to use. These constraints prevent the AI from sending empty strings, overly long inputs, or incorrectly formatted data.

typescript
1// TypeScript
2server.registerTool("create-user", {
3 description: "Create a new user account",
4 inputSchema: {
5 username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/)
6 .describe("Username (3-30 chars, alphanumeric and underscores)"),
7 email: z.string().email()
8 .describe("Valid email address"),
9 bio: z.string().max(500).optional()
10 .describe("Optional user bio, max 500 characters"),
11 },
12}, async ({ username, email, bio }) => {
13 // Parameters are already validated by Zod
14 const user = await createUser({ username, email, bio });
15 return { content: [{ type: "text", text: `Created user: ${user.id}` }] };
16});

Expected result: The tool rejects calls with invalid usernames, malformed emails, or bios over 500 characters before your handler executes.

2

Validate numbers with ranges and constraints

Use z.number() with .min(), .max(), .int(), .positive(), and .nonnegative() for numeric parameters. For tools that accept page sizes, limits, or quantities, setting bounds prevents the AI from requesting impossibly large datasets or negative values.

typescript
1// TypeScript
2server.registerTool("search-records", {
3 description: "Search database records with pagination",
4 inputSchema: {
5 query: z.string().min(1).describe("Search query"),
6 page: z.number().int().min(1).default(1)
7 .describe("Page number, starting from 1"),
8 pageSize: z.number().int().min(1).max(100).default(20)
9 .describe("Results per page, 1-100"),
10 minScore: z.number().min(0).max(1).optional()
11 .describe("Minimum relevance score (0.0-1.0)"),
12 },
13}, async ({ query, page, pageSize, minScore }) => {
14 const results = await search(query, { page, pageSize, minScore });
15 return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
16});
17
18# Python equivalent
19@mcp.tool()
20async def search_records(
21 query: str,
22 page: int = 1,
23 page_size: int = 20,
24 min_score: float | None = None,
25) -> str:
26 """Search database records with pagination.
27
28 Args:
29 query: Search query
30 page: Page number, starting from 1
31 page_size: Results per page, 1-100
32 min_score: Minimum relevance score (0.0-1.0)
33 """
34 results = await search(query, page=page, page_size=page_size, min_score=min_score)
35 return json.dumps(results, indent=2)

Expected result: Numeric parameters are validated against bounds and default values are applied when the AI omits optional parameters.

3

Use enums for constrained choice parameters

When a parameter should only accept specific values, use z.enum() to define the allowed options. This is clearer to the AI than a free-form string and prevents invalid values. Enums work well for status values, sort directions, output formats, and action types.

typescript
1// TypeScript
2server.registerTool("list-tasks", {
3 description: "List project tasks with filtering and sorting",
4 inputSchema: {
5 status: z.enum(["open", "in-progress", "done", "cancelled"])
6 .describe("Filter by task status"),
7 priority: z.enum(["low", "medium", "high", "critical"]).optional()
8 .describe("Filter by priority level"),
9 sortBy: z.enum(["created", "updated", "priority", "title"]).default("updated")
10 .describe("Field to sort results by"),
11 sortOrder: z.enum(["asc", "desc"]).default("desc")
12 .describe("Sort direction"),
13 },
14}, async ({ status, priority, sortBy, sortOrder }) => {
15 const tasks = await listTasks({ status, priority, sortBy, sortOrder });
16 return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
17});

Expected result: The AI can only pass valid enum values. Invalid values like 'pending' or 'ascending' are rejected before the handler runs.

4

Handle arrays and nested objects

For tools that accept lists or structured data, use z.array() and z.object(). Array validators support .min() and .max() for length constraints. Nested objects let you group related parameters. This is essential for batch operations or tools that accept complex input.

typescript
1// TypeScript
2server.registerTool("send-notifications", {
3 description: "Send notifications to multiple users",
4 inputSchema: {
5 recipients: z.array(z.string().email()).min(1).max(50)
6 .describe("List of email addresses to notify (1-50)"),
7 message: z.object({
8 subject: z.string().min(1).max(200).describe("Notification subject"),
9 body: z.string().min(1).max(5000).describe("Notification body text"),
10 priority: z.enum(["low", "normal", "urgent"]).default("normal")
11 .describe("Message priority"),
12 }).describe("Notification content"),
13 dryRun: z.boolean().default(false)
14 .describe("If true, validate inputs without sending"),
15 },
16}, async ({ recipients, message, dryRun }) => {
17 if (dryRun) {
18 return { content: [{ type: "text", text: `Dry run: would notify ${recipients.length} recipients` }] };
19 }
20 await sendNotifications(recipients, message);
21 return { content: [{ type: "text", text: `Sent to ${recipients.length} recipients` }] };
22});

Expected result: The tool validates array length, individual array item types, and nested object field constraints.

5

Add business logic validation inside handlers

Zod validates data types and formats, but you still need business logic validation inside your handler. Check authorization, verify referenced IDs exist, validate cross-field dependencies, and enforce rate limits. Return isError: true for business logic failures so the AI can adjust its approach. For complex validation pipelines, the RapidDev team can help architect robust error handling layers.

typescript
1// TypeScript
2server.registerTool("transfer-funds", {
3 description: "Transfer funds between accounts",
4 inputSchema: {
5 fromAccount: z.string().describe("Source account ID"),
6 toAccount: z.string().describe("Destination account ID"),
7 amount: z.number().positive().max(10000).describe("Amount to transfer (max 10,000)"),
8 currency: z.enum(["USD", "EUR", "GBP"]).describe("Currency"),
9 },
10}, async ({ fromAccount, toAccount, amount, currency }) => {
11 // Business logic validation
12 if (fromAccount === toAccount) {
13 return {
14 content: [{ type: "text", text: "Error: Source and destination accounts must be different" }],
15 isError: true,
16 };
17 }
18 const balance = await getBalance(fromAccount, currency);
19 if (balance < amount) {
20 return {
21 content: [{ type: "text", text: `Error: Insufficient funds. Balance: ${balance} ${currency}` }],
22 isError: true,
23 };
24 }
25 const result = await executeTransfer(fromAccount, toAccount, amount, currency);
26 return { content: [{ type: "text", text: `Transfer complete. ID: ${result.id}` }] };
27});

Expected result: The tool rejects transfers to the same account and insufficient balance scenarios with clear error messages.

Complete working example

src/index.ts
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { z } from "zod";
4
5const server = new McpServer({
6 name: "validated-tools-server",
7 version: "1.0.0",
8});
9
10// String constraints
11server.registerTool("create-user", {
12 description: "Create a new user account",
13 inputSchema: {
14 username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/)
15 .describe("Username: 3-30 chars, alphanumeric and underscores"),
16 email: z.string().email().describe("Valid email address"),
17 role: z.enum(["viewer", "editor", "admin"]).default("viewer")
18 .describe("User role"),
19 },
20}, async ({ username, email, role }) => {
21 return {
22 content: [{ type: "text", text: `Created ${role} user: ${username} (${email})` }],
23 };
24});
25
26// Number ranges and pagination
27server.registerTool("search-records", {
28 description: "Search records with pagination",
29 inputSchema: {
30 query: z.string().min(1).describe("Search query"),
31 page: z.number().int().min(1).default(1).describe("Page number"),
32 pageSize: z.number().int().min(1).max(100).default(20)
33 .describe("Results per page (1-100)"),
34 },
35}, async ({ query, page, pageSize }) => {
36 return {
37 content: [{ type: "text", text: `Searching '${query}' page ${page} (${pageSize}/page)` }],
38 };
39});
40
41// Arrays and nested objects
42server.registerTool("bulk-update", {
43 description: "Update multiple records at once",
44 inputSchema: {
45 updates: z.array(z.object({
46 id: z.string().describe("Record ID"),
47 field: z.string().describe("Field to update"),
48 value: z.string().describe("New value"),
49 })).min(1).max(50).describe("List of updates (1-50)"),
50 dryRun: z.boolean().default(false).describe("Validate without applying"),
51 },
52}, async ({ updates, dryRun }) => {
53 const action = dryRun ? "Would update" : "Updated";
54 return {
55 content: [{ type: "text", text: `${action} ${updates.length} records` }],
56 };
57});
58
59// Business logic validation
60server.registerTool("delete-record", {
61 description: "Delete a record by ID",
62 inputSchema: {
63 id: z.string().min(1).describe("Record ID to delete"),
64 confirm: z.boolean().describe("Must be true to confirm deletion"),
65 },
66}, async ({ id, confirm }) => {
67 if (!confirm) {
68 return {
69 content: [{ type: "text", text: "Error: Set confirm to true to proceed with deletion" }],
70 isError: true,
71 };
72 }
73 return { content: [{ type: "text", text: `Deleted record: ${id}` }] };
74});
75
76const transport = new StdioServerTransport();
77await server.connect(transport);
78console.error("Validated tools server running on stdio");

Common mistakes when adding Zod input validation to MCP tools

Why it's a problem: Not adding .describe() to Zod parameters

How to avoid: Always add .describe() with a clear explanation of the expected value. The AI uses this text to generate correct arguments.

Why it's a problem: Using z.any() or z.unknown() for convenience

How to avoid: These bypass validation entirely. Define specific types for every parameter, even if the schema is verbose.

Why it's a problem: Setting unreasonably large max values

How to avoid: Bound arrays, strings, and numbers to practical limits. z.array().max(1000) may let the AI send massive payloads that overwhelm your handler.

Why it's a problem: Relying only on Zod for all validation

How to avoid: Zod handles type and format validation. Add business logic checks (authorization, existence checks, cross-field rules) inside your handler.

Why it's a problem: Deeply nesting objects (3+ levels)

How to avoid: AI models struggle with deeply nested schemas. Flatten where possible or split into multiple tools.

Best practices

  • Add .describe() to every parameter — this is what the AI reads to generate correct arguments
  • Use .default() for optional parameters so the tool works even if the AI omits them
  • Set realistic .min() and .max() bounds on strings, numbers, and arrays
  • Prefer z.enum() over z.string() when the parameter has a fixed set of valid values
  • Keep object nesting to 1-2 levels — flatten complex schemas for better AI accuracy
  • Add a dryRun boolean parameter to destructive tools for safety
  • Validate cross-field dependencies inside the handler with clear isError messages
  • Test validation with edge cases: empty strings, zero, negative numbers, empty arrays

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm building an MCP server tool that accepts [describe inputs]. Show me the Zod inputSchema with proper constraints (.min, .max, .regex, .enum, .default, .describe) and the matching handler with business logic validation.

MCP Prompt

Add input validation to my MCP tool [tool-name]. The parameters need: [list constraints like 'username must be 3-30 alphanumeric chars', 'amount must be positive and under 10000']. Use Zod validators in the inputSchema and return isError: true for business rule violations.

Frequently asked questions

Does Zod validation happen before or after my handler runs?

Before. The MCP SDK validates the input against your Zod schema before calling your handler function. If validation fails, the client receives an error response and your handler never executes.

What error does the AI see when validation fails?

The SDK returns a JSON-RPC error with a message describing which parameter failed and why (e.g., 'Expected string, received number' or 'String must be at least 3 characters'). The AI can use this to retry with corrected arguments.

Can I use z.union() or z.discriminatedUnion() in MCP tools?

Yes, but use sparingly. Union types are harder for AI models to understand than simple types. If possible, split into separate tools instead of using complex unions.

How do I validate dates in MCP tools?

Use z.string().datetime() for ISO 8601 strings, or z.string().date() for date-only strings. Add .describe('ISO 8601 datetime, e.g. 2026-03-28T10:00:00Z') so the AI formats correctly.

Should I validate in both the schema and the handler?

Yes. The Zod schema handles type-level validation (format, bounds, required vs optional). The handler handles business logic validation (does the ID exist, does the user have permission, are cross-field values consistent). Both layers are important.

What about Python MCP tools — how do I validate there?

Python FastMCP uses function parameter type hints for basic validation and Pydantic models for complex schemas. Define a Pydantic BaseModel with Field() validators and use it as a parameter type. For teams needing robust validation across both TypeScript and Python MCP servers, RapidDev can help standardize your approach.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.