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
Add string constraints to tool parameters
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.
1// TypeScript2server.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 Zod14 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.
Validate numbers with ranges and constraints
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.
1// TypeScript2server.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});1718# Python equivalent19@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.2728 Args:29 query: Search query30 page: Page number, starting from 131 page_size: Results per page, 1-10032 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.
Use enums for constrained choice parameters
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.
1// TypeScript2server.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.
Handle arrays and nested objects
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.
1// TypeScript2server.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.
Add business logic validation inside handlers
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.
1// TypeScript2server.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 validation12 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
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { z } from "zod";45const server = new McpServer({6 name: "validated-tools-server",7 version: "1.0.0",8});910// String constraints11server.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});2526// Number ranges and pagination27server.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});4041// Arrays and nested objects42server.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});5859// Business logic validation60server.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});7576const 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation