Mastering Zod Validation in MCP Servers with vercel/mcp-handler
Zod is the backbone of type-safe validation in MCP servers. When building with vercel/mcp-handler
, Zod schemas define the contract between AI models and your tools, ensuring data integrity and providing rich TypeScript inference. This guide covers every Zod feature you'll need, with practical MCP-specific examples.
Core Zod Concepts for MCP Servers
Think of Zod schemas in MCP as API contracts for AI. Each tool you expose needs clear input validation - just like REST endpoints need request validation, but with the added complexity that AI models generate the inputs dynamically.
// Traditional API: Human developers read docs and send correct data
// MCP Tool: AI models read schemas and generate matching data
// Zod ensures both sides of this contract are enforced
Primitive Validation Patterns
String Validation with Business Rules
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";
const handler = createMcpHandler((server) => {
server.tool(
"user_registration",
"Register a new user with validated inputs",
{
// Email with custom domain validation
email: z
.string()
.email("Invalid email format")
.refine(
(email) => email.endsWith("@company.com") || email.endsWith("@partner.org"),
"Email must be from company.com or partner.org domain"
),
// Username with complex requirements
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username cannot exceed 20 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscore, and hyphen")
.transform((val) => val.toLowerCase()), // Always lowercase
// Password with security requirements
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"),
// Optional bio with length constraints
bio: z
.string()
.max(500, "Bio cannot exceed 500 characters")
.optional()
.transform((val) => val?.trim()), // Trim whitespace if provided
},
async ({ email, username, password, bio }) => {
// All inputs are validated and typed
const user = await createUser({ email, username, password, bio });
return {
content: [{
type: "text",
text: `User ${username} created successfully with ID: ${user.id}`
}],
};
}
);
});
Number Validation with Ranges
server.tool(
"financial_transaction",
"Process financial transactions with strict validation",
{
amount: z
.number()
.positive("Amount must be positive")
.multipleOf(0.01, "Amount must be to 2 decimal places")
.max(1000000, "Single transaction limit exceeded"),
quantity: z
.number()
.int("Quantity must be a whole number")
.min(1, "Quantity must be at least 1")
.max(100, "Maximum quantity is 100"),
percentage: z
.number()
.min(0, "Percentage cannot be negative")
.max(100, "Percentage cannot exceed 100")
.transform((val) => val / 100), // Convert to decimal
temperature: z
.number()
.finite("Temperature must be a finite number")
.refine(
(val) => val >= -273.15,
"Temperature cannot be below absolute zero"
),
},
async (params) => {
// Process validated transaction
return { content: [{ type: "text", text: "Transaction processed" }] };
}
);
Boolean and Date Validation
server.tool(
"schedule_meeting",
"Schedule a meeting with date/time validation",
{
title: z.string().min(1, "Title is required"),
// Date validation with business logic
startDate: z
.string()
.datetime({ message: "Invalid datetime format" })
.refine((date) => {
const d = new Date(date);
return d > new Date();
}, "Start date must be in the future")
.transform((str) => new Date(str)),
// Duration in minutes
duration: z
.number()
.int()
.min(15, "Minimum meeting duration is 15 minutes")
.max(480, "Maximum meeting duration is 8 hours"),
// Boolean flags with defaults
isRecurring: z.boolean().default(false),
sendReminders: z.boolean().default(true),
requiresApproval: z.boolean().optional(),
// Coerce string to boolean (useful for form data)
isPublic: z
.string()
.transform((val) => val === "true" || val === "1")
.or(z.boolean())
.default(false),
},
async (meeting) => {
// meeting.startDate is now a Date object
const scheduled = await scheduleMeeting(meeting);
return {
content: [{
type: "text",
text: `Meeting scheduled for ${meeting.startDate.toISOString()}`
}],
};
}
);
Advanced Object Schemas
Nested Objects with Validation
server.tool(
"create_project",
"Create a project with complex nested structure",
{
project: z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
// Nested object for settings
settings: z.object({
visibility: z.enum(["public", "private", "team"]),
autoArchive: z.boolean().default(false),
maxMembers: z.number().int().min(1).max(1000).default(50),
}),
// Nested object with validation dependencies
budget: z.object({
amount: z.number().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
allocated: z.number().default(0),
}).refine(
(budget) => budget.allocated <= budget.amount,
"Allocated budget cannot exceed total amount"
),
// Array of nested objects
milestones: z.array(
z.object({
title: z.string(),
dueDate: z.string().datetime(),
status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
})
).min(1, "At least one milestone is required")
.max(20, "Maximum 20 milestones allowed"),
}),
},
async ({ project }) => {
const created = await createProject(project);
return {
content: [{
type: "text",
text: `Project "${project.name}" created with ${project.milestones.length} milestones`
}],
};
}
);
Dynamic Object Keys with Records
server.tool(
"update_metadata",
"Update metadata with dynamic key-value pairs",
{
entityId: z.string().uuid("Invalid entity ID format"),
// Record with string keys and various value types
metadata: z.record(
z.string().regex(/^[a-z_][a-z0-9_]*$/i, "Invalid metadata key format"),
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
])
),
// Record with enum keys
permissions: z.record(
z.enum(["read", "write", "delete", "admin"]),
z.boolean()
),
// Nested records for complex structures
translations: z.record(
z.string().length(2, "Language code must be 2 characters"), // e.g., "en", "fr"
z.object({
title: z.string(),
description: z.string().optional(),
})
),
},
async ({ entityId, metadata, permissions, translations }) => {
// Type-safe access to dynamic keys
const result = await updateEntity(entityId, { metadata, permissions, translations });
return {
content: [{
type: "text",
text: `Updated entity ${entityId} with ${Object.keys(metadata).length} metadata fields`
}],
};
}
);
Arrays and Tuples
Array Validation with Constraints
server.tool(
"batch_process",
"Process batch operations with array validation",
{
// Simple array with length constraints
ids: z
.array(z.string().uuid())
.min(1, "At least one ID required")
.max(100, "Maximum 100 items per batch"),
// Array with unique values
tags: z
.array(z.string())
.refine(
(tags) => new Set(tags).size === tags.length,
"Tags must be unique"
),
// Array of objects with complex validation
operations: z
.array(
z.object({
type: z.enum(["create", "update", "delete"]),
data: z.record(z.any()),
priority: z.number().int().min(1).max(10).default(5),
})
)
.transform((ops) =>
// Sort by priority
ops.sort((a, b) => b.priority - a.priority)
),
// Tuple for fixed-length arrays
coordinates: z.tuple([
z.number().min(-90).max(90), // latitude
z.number().min(-180).max(180), // longitude
]),
// Rest elements in tuples
version: z.tuple([
z.number().int(), // major
z.number().int(), // minor
z.number().int(), // patch
]).rest(z.string()), // Can have additional string elements
},
async ({ ids, tags, operations, coordinates, version }) => {
// coordinates is typed as [number, number]
// version is typed as [number, number, number, ...string[]]
const results = await processBatch({ ids, tags, operations });
return {
content: [{
type: "text",
text: `Processed ${operations.length} operations at coordinates ${coordinates.join(", ")}`
}],
};
}
);
Enums and Discriminated Unions
Enum Patterns
// Define enum values as const for reusability
const TaskStatus = {
PENDING: "pending",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
} as const;
const TaskPriority = ["low", "medium", "high", "urgent"] as const;
server.tool(
"task_management",
"Manage tasks with enum validation",
{
action: z.enum(["create", "update", "delete", "archive"]),
// Using object values as enum
status: z.enum(Object.values(TaskStatus) as [string, ...string[]]),
// Using array as enum
priority: z.enum(TaskPriority),
// Optional enum with default
assigneeRole: z
.enum(["developer", "designer", "manager", "qa"])
.optional()
.default("developer"),
// Transform enum values
visibility: z
.enum(["0", "1", "2"]) // Incoming as strings
.transform((val) => {
const map = { "0": "private", "1": "team", "2": "public" };
return map[val as keyof typeof map];
}),
},
async (params) => {
const result = await manageTask(params);
return {
content: [{
type: "text",
text: `Task ${params.action}d with status: ${params.status}`
}],
};
}
);
Discriminated Unions for Complex Types
server.tool(
"process_payment",
"Process different payment types with discriminated unions",
{
payment: z.discriminatedUnion("type", [
z.object({
type: z.literal("credit_card"),
cardNumber: z.string().regex(/^\d{16}$/, "Card number must be 16 digits"),
cvv: z.string().regex(/^\d{3,4}$/, "CVV must be 3-4 digits"),
expiryMonth: z.number().int().min(1).max(12),
expiryYear: z.number().int().min(new Date().getFullYear()),
}),
z.object({
type: z.literal("bank_transfer"),
accountNumber: z.string(),
routingNumber: z.string(),
accountType: z.enum(["checking", "savings"]),
}),
z.object({
type: z.literal("crypto"),
walletAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid wallet address"),
network: z.enum(["ethereum", "polygon", "arbitrum"]),
tokenAddress: z.string().optional(),
}),
z.object({
type: z.literal("paypal"),
email: z.string().email(),
paypalId: z.string().optional(),
}),
]),
amount: z.number().positive(),
currency: z.string().length(3, "Currency code must be 3 characters"),
},
async ({ payment, amount, currency }) => {
// TypeScript knows the exact shape based on payment.type
if (payment.type === "credit_card") {
// payment.cardNumber is available here
return await processCreditCard(payment, amount, currency);
} else if (payment.type === "crypto") {
// payment.walletAddress is available here
return await processCrypto(payment, amount, currency);
}
// ... handle other payment types
return {
content: [{
type: "text",
text: `Payment of ${amount} ${currency} processed via ${payment.type}`
}],
};
}
);
Transformations and Preprocessing
Data Transformation Pipeline
server.tool(
"import_data",
"Import and transform data with preprocessing",
{
// Coerce string to number
userId: z.string().transform((val) => parseInt(val, 10)),
// Parse JSON string
config: z
.string()
.transform((str) => {
try {
return JSON.parse(str);
} catch {
throw new Error("Invalid JSON configuration");
}
})
.pipe(
z.object({
enabled: z.boolean(),
threshold: z.number(),
})
),
// Clean and normalize data
phone: z
.string()
.transform((phone) => phone.replace(/\D/g, "")) // Remove non-digits
.refine((phone) => phone.length === 10, "Phone must be 10 digits")
.transform((phone) => `+1${phone}`), // Add country code
// Parse comma-separated values
tags: z
.string()
.transform((str) => str.split(",").map(s => s.trim()))
.pipe(z.array(z.string().min(1))),
// Complex transformation with validation
dateRange: z
.string()
.transform((str) => {
const [start, end] = str.split(" to ").map(d => new Date(d.trim()));
return { start, end };
})
.refine(
({ start, end }) => start < end,
"Start date must be before end date"
),
},
async (data) => {
// All data is transformed and validated
const result = await importData(data);
return {
content: [{
type: "text",
text: `Imported data for user ${data.userId} with ${data.tags.length} tags`
}],
};
}
);
Preprocessing for Normalization
server.tool(
"search_products",
"Search products with input preprocessing",
{
// Preprocess to handle various input formats
query: z.preprocess(
(val) => {
if (typeof val === "string") return val.trim().toLowerCase();
if (typeof val === "number") return String(val);
return val;
},
z.string().min(1, "Search query required")
),
// Coerce and validate price range
minPrice: z.preprocess(
(val) => (val === "" || val === null ? undefined : Number(val)),
z.number().min(0).optional()
),
maxPrice: z.preprocess(
(val) => (val === "" || val === null ? undefined : Number(val)),
z.number().positive().optional()
),
// Handle boolean-like values
inStock: z.preprocess(
(val) => {
if (typeof val === "boolean") return val;
if (val === "true" || val === "1" || val === 1) return true;
if (val === "false" || val === "0" || val === 0) return false;
return undefined;
},
z.boolean().optional()
),
// Parse and validate date strings
availableAfter: z.preprocess(
(val) => {
if (!val) return undefined;
const date = new Date(String(val));
return isNaN(date.getTime()) ? undefined : date;
},
z.date().optional()
),
},
async (params) => {
const products = await searchProducts(params);
return {
content: [{
type: "text",
text: `Found ${products.length} products matching "${params.query}"`
}],
};
}
);
Refinements and Custom Validation
Basic Refinements
server.tool(
"create_event",
"Create event with complex validation rules",
{
name: z.string(),
startTime: z.string().datetime(),
endTime: z.string().datetime(),
maxAttendees: z.number().int().positive(),
currentAttendees: z.number().int().min(0).default(0),
// Object-level refinement
location: z.object({
type: z.enum(["physical", "virtual", "hybrid"]),
address: z.string().optional(),
url: z.string().url().optional(),
capacity: z.number().int().positive().optional(),
}).refine(
(loc) => {
if (loc.type === "physical" && !loc.address) {
return false;
}
if (loc.type === "virtual" && !loc.url) {
return false;
}
return true;
},
{
message: "Physical events need address, virtual events need URL",
}
),
},
async (eventData) => {
const event = await createEvent(eventData);
return {
content: [{
type: "text",
text: `Event "${eventData.name}" created successfully`
}],
};
}
);
SuperRefine for Multiple Validations
server.tool(
"configure_deployment",
"Configure deployment with interdependent validations",
{
environment: z.enum(["development", "staging", "production"]),
replicas: z.number().int().positive(),
memory: z.number().positive(), // in GB
cpu: z.number().positive(), // in cores
autoScaling: z.boolean(),
minReplicas: z.number().int().positive().optional(),
maxReplicas: z.number().int().positive().optional(),
customDomain: z.string().optional(),
ssl: z.boolean().optional(),
},
async (config) => {
// Input is validated against all superRefine rules
const deployment = await configureDeployment(config);
return {
content: [{
type: "text",
text: `Deployment configured for ${config.environment} with ${config.replicas} replicas`
}],
};
}
);
// Add superRefine to the schema for complex multi-field validation
const deploymentSchema = z
.object({
environment: z.enum(["development", "staging", "production"]),
replicas: z.number().int().positive(),
memory: z.number().positive(),
cpu: z.number().positive(),
autoScaling: z.boolean(),
minReplicas: z.number().int().positive().optional(),
maxReplicas: z.number().int().positive().optional(),
customDomain: z.string().optional(),
ssl: z.boolean().optional(),
})
.superRefine((data, ctx) => {
// Production-specific validations
if (data.environment === "production") {
if (data.replicas < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Production must have at least 2 replicas",
path: ["replicas"],
});
}
if (data.memory < 4) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
message: "Production requires at least 4GB memory",
path: ["memory"],
minimum: 4,
inclusive: true,
type: "number",
});
}
}
// Auto-scaling validations
if (data.autoScaling) {
if (!data.minReplicas || !data.maxReplicas) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Auto-scaling requires both minReplicas and maxReplicas",
});
}
if (data.minReplicas && data.maxReplicas && data.minReplicas > data.maxReplicas) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "minReplicas cannot be greater than maxReplicas",
path: ["minReplicas"],
});
}
}
// Custom domain requires SSL
if (data.customDomain && !data.ssl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Custom domain requires SSL to be enabled",
path: ["ssl"],
});
}
// Resource ratio validation
const cpuMemoryRatio = data.cpu / data.memory;
if (cpuMemoryRatio > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "CPU cores should not exceed memory (GB)",
});
}
});
Async Refinements
server.tool(
"register_domain",
"Register domain with async validation",
{
domain: z
.string()
.regex(/^[a-z0-9-]+\.[a-z]{2,}$/, "Invalid domain format")
.refine(async (domain) => {
// Check if domain is available (async operation)
const available = await checkDomainAvailability(domain);
return available;
}, "Domain is not available"),
owner: z.object({
email: z
.string()
.email()
.refine(async (email) => {
// Verify email doesn't exist in blacklist
const blacklisted = await checkEmailBlacklist(email);
return !blacklisted;
}, "Email is blacklisted"),
organizationId: z
.string()
.uuid()
.refine(async (id) => {
// Verify organization exists
const exists = await organizationExists(id);
return exists;
}, "Organization not found"),
}),
duration: z.number().int().min(1).max(10), // years
},
async ({ domain, owner, duration }) => {
const registration = await registerDomain({ domain, owner, duration });
return {
content: [{
type: "text",
text: `Domain ${domain} registered for ${duration} years`
}],
};
}
);
Union Types and Optional Handling
Flexible Input Types
server.tool(
"flexible_query",
"Handle multiple input formats with unions",
{
// String or number ID
identifier: z.union([
z.string().uuid(),
z.number().int().positive(),
z.string().regex(/^[A-Z]{2,4}-\d{6}$/), // Custom format
]),
// Multiple date formats
date: z.union([
z.string().datetime(),
z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
z.number(), // Unix timestamp
]).transform((val) => {
if (typeof val === "number") return new Date(val * 1000);
return new Date(val);
}),
// Optional with multiple types
filter: z
.union([
z.string(),
z.array(z.string()),
z.object({
include: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
}),
])
.optional()
.transform((filter) => {
// Normalize to consistent format
if (!filter) return { include: [], exclude: [] };
if (typeof filter === "string") return { include: [filter], exclude: [] };
if (Array.isArray(filter)) return { include: filter, exclude: [] };
return filter;
}),
// Nullable vs optional
description: z.string().nullable(), // Can be null
metadata: z.record(z.any()).optional(), // Can be undefined
notes: z.string().nullish(), // Can be null or undefined
},
async (params) => {
const result = await executeQuery(params);
return {
content: [{
type: "text",
text: `Query executed with identifier: ${params.identifier}`
}],
};
}
);
Error Handling and Custom Messages
Comprehensive Error Messages
const userInputSchema = z.object({
username: z
.string({
required_error: "Username is required",
invalid_type_error: "Username must be a string",
})
.min(3, { message: "Username must be at least 3 characters long" })
.max(20, { message: "Username cannot exceed 20 characters" })
.regex(/^[a-zA-Z0-9_]+$/, {
message: "Username can only contain letters, numbers, and underscores"
}),
age: z
.number({
required_error: "Age is required",
invalid_type_error: "Age must be a number",
})
.int({ message: "Age must be a whole number" })
.positive({ message: "Age must be positive" })
.max(120, { message: "Age cannot exceed 120" }),
email: z
.string()
.email({ message: "Please provide a valid email address" })
.refine(
(email) => !email.includes("tempmail"),
{ message: "Temporary email addresses are not allowed" }
),
});
server.tool(
"validate_user",
"Validate user input with detailed error messages",
userInputSchema.shape,
async (userData) => {
// If we get here, all validation passed
return {
content: [{
type: "text",
text: `User ${userData.username} validated successfully`
}],
};
}
);
Error Path Specification
const complexSchema = z
.object({
user: z.object({
profile: z.object({
firstName: z.string(),
lastName: z.string(),
age: z.number(),
}),
settings: z.object({
notifications: z.boolean(),
theme: z.enum(["light", "dark"]),
}),
}),
permissions: z.array(z.string()),
})
.superRefine((data, ctx) => {
if (data.user.profile.age < 18 && data.permissions.includes("admin")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Users under 18 cannot have admin permissions",
path: ["permissions"], // Specify exact path for error
});
}
if (data.user.profile.firstName === data.user.profile.lastName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "First name and last name must be different",
path: ["user", "profile", "lastName"], // Nested path
});
}
});
Performance Optimization Patterns
Lazy Schemas for Recursive Structures
// Define recursive types (e.g., tree structures, nested comments)
type TreeNode = {
id: string;
value: any;
children?: TreeNode[];
};
const treeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
z.object({
id: z.string().uuid(),
value: z.any(),
children: z.array(treeNodeSchema).optional(),
})
);
server.tool(
"process_tree",
"Process hierarchical tree structure",
{
tree: treeNodeSchema,
maxDepth: z.number().int().positive().max(10).default(5),
},
async ({ tree, maxDepth }) => {
const result = await processTreeStructure(tree, maxDepth);
return {
content: [{
type: "text",
text: `Processed tree with root ID: ${tree.id}`
}],
};
}
);
Schema Composition and Reuse
// Base schemas for reuse
const addressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().default("US"),
});
const contactSchema = z.object({
phone: z.string().regex(/^\+?[\d\s-()]+$/),
email: z.string().email(),
preferredContact: z.enum(["phone", "email", "both"]).default("email"),
});
// Compose schemas using intersection
const personSchema = z.intersection(
z.object({
id: z.string().uuid(),
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.string().date(),
}),
contactSchema
);
// Extend schemas for specific use cases
const employeeSchema = personSchema.extend({
employeeId: z.string(),
department: z.enum(["engineering", "sales", "hr", "finance"]),
salary: z.number().positive(),
address: addressSchema,
emergencyContact: contactSchema.partial(), // Make all fields optional
});
const customerSchema = personSchema.extend({
customerId: z.string(),
loyaltyPoints: z.number().int().min(0).default(0),
shippingAddress: addressSchema,
billingAddress: addressSchema.optional(),
preferences: z.object({
newsletter: z.boolean().default(true),
smsAlerts: z.boolean().default(false),
}),
});
server.tool(
"manage_person",
"Manage employee or customer records",
{
type: z.enum(["employee", "customer"]),
data: z.union([employeeSchema, customerSchema]),
action: z.enum(["create", "update", "archive"]),
},
async ({ type, data, action }) => {
// TypeScript narrows the type based on the discriminated union
if (type === "employee") {
return await manageEmployee(data as z.infer<typeof employeeSchema>, action);
} else {
return await manageCustomer(data as z.infer<typeof customerSchema>, action);
}
}
);
Real-World MCP Tool Examples
Complete File Processing Tool
const FileFormat = z.enum(["csv", "json", "xml", "yaml", "txt"]);
const Operation = z.enum(["parse", "validate", "transform", "merge"]);
server.tool(
"process_files",
"Advanced file processing with multiple operations",
{
files: z.array(
z.object({
name: z.string().regex(/^[\w\-. ]+$/, "Invalid filename"),
format: FileFormat,
content: z.string().max(10 * 1024 * 1024, "File size exceeds 10MB"),
encoding: z.enum(["utf8", "base64", "ascii"]).default("utf8"),
})
).min(1, "At least one file required")
.max(10, "Maximum 10 files at once"),
operation: Operation,
options: z.discriminatedUnion("operation", [
z.object({
operation: z.literal("parse"),
delimiter: z.string().optional(),
headers: z.boolean().default(true),
skipRows: z.number().int().min(0).default(0),
}),
z.object({
operation: z.literal("validate"),
schema: z.record(z.any()), // JSON schema for validation
strict: z.boolean().default(false),
}),
z.object({
operation: z.literal("transform"),
transformations: z.array(
z.object({
field: z.string(),
operation: z.enum(["uppercase", "lowercase", "trim", "replace", "remove"]),
value: z.string().optional(),
})
),
}),
z.object({
operation: z.literal("merge"),
mergeKey: z.string(),
conflictResolution: z.enum(["keep_first", "keep_last", "combine"]),
}),
]),
output: z.object({
format: FileFormat,
compression: z.enum(["none", "gzip", "zip"]).default("none"),
splitSize: z.number().int().positive().optional(), // KB
}),
},
async ({ files, operation, options, output }) => {
// Process files based on operation type
let result;
switch (operation) {
case "parse":
result = await parseFiles(files, options);
break;
case "validate":
result = await validateFiles(files, options);
break;
case "transform":
result = await transformFiles(files, options);
break;
case "merge":
result = await mergeFiles(files, options);
break;
}
// Format output
const formatted = await formatOutput(result, output);
return {
content: [{
type: "text",
text: `Processed ${files.length} files with ${operation} operation. Output: ${output.format}`
}],
};
}
);
API Integration Tool with Rate Limiting
server.tool(
"api_request",
"Make API requests with validation and rate limiting",
{
endpoint: z
.string()
.url("Invalid URL format")
.refine(
(url) => {
const allowedDomains = ["api.example.com", "api.partner.com"];
const urlObj = new URL(url);
return allowedDomains.includes(urlObj.hostname);
},
"API domain not whitelisted"
),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
headers: z
.record(z.string())
.default({})
.transform((headers) => ({
...headers,
"Content-Type": headers["Content-Type"] || "application/json",
"User-Agent": "MCP-Server/1.0",
})),
body: z
.any()
.optional()
.refine(
(body, ctx) => {
const method = ctx.path[0] === "POST" || ctx.path[0] === "PUT" || ctx.path[0] === "PATCH";
if (method && !body) {
return false;
}
return true;
},
"Body required for POST/PUT/PATCH requests"
),
auth: z.discriminatedUnion("type", [
z.object({
type: z.literal("bearer"),
token: z.string().min(1),
}),
z.object({
type: z.literal("api_key"),
key: z.string().min(1),
location: z.enum(["header", "query"]),
}),
z.object({
type: z.literal("basic"),
username: z.string(),
password: z.string(),
}),
z.object({
type: z.literal("oauth2"),
clientId: z.string(),
clientSecret: z.string(),
scope: z.array(z.string()).optional(),
}),
]).optional(),
retry: z.object({
maxAttempts: z.number().int().min(1).max(5).default(3),
backoffMs: z.number().int().min(100).max(60000).default(1000),
retryOn: z.array(z.number().int()).default([429, 500, 502, 503, 504]),
}).optional(),
timeout: z.number().int().min(1000).max(300000).default(30000), // ms
},
async (params) => {
// Apply rate limiting
await rateLimiter.checkLimit(params.endpoint);
// Build request with auth
const request = buildAuthenticatedRequest(params);
// Execute with retry logic
const response = await executeWithRetry(request, params.retry);
return {
content: [{
type: "text",
text: `API request to ${params.endpoint} completed with status ${response.status}`
}],
};
}
);
Best Practices for MCP Zod Schemas
1. Use Descriptive Error Messages
// ❌ Poor: Generic messages
z.string().min(3)
// ✅ Good: Specific, actionable messages
z.string().min(3, "Username must be at least 3 characters long")
2. Provide Defaults Where Sensible
// ✅ Good: Defaults for optional configs
z.object({
retryCount: z.number().int().default(3),
timeout: z.number().default(30000),
debug: z.boolean().default(false),
})
3. Use Transform for Data Normalization
// ✅ Good: Normalize data early
z.object({
email: z.string().email().transform(e => e.toLowerCase()),
tags: z.string().transform(s => s.split(",").map(t => t.trim())),
})
4. Leverage Discriminated Unions for Complex Types
// ✅ Good: Type-safe branching
z.discriminatedUnion("type", [
z.object({ type: z.literal("A"), fieldA: z.string() }),
z.object({ type: z.literal("B"), fieldB: z.number() }),
])
5. Use Refinements for Business Logic
// ✅ Good: Encapsulate business rules
z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(
data => data.startDate < data.endDate,
"End date must be after start date"
)
6. Create Reusable Schema Components
// ✅ Good: DRY principle
const paginationSchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
});
// Reuse in multiple tools
server.tool("list_users", "...", {
...paginationSchema.shape,
filter: z.string().optional(),
});
7. Handle Optional vs Nullable Appropriately
// Understand the difference:
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
Conclusion
Zod provides a powerful, type-safe foundation for MCP server validation. By leveraging its comprehensive API - from basic primitives to complex discriminated unions, transformations, and refinements - you can build robust MCP tools that handle any data structure AI models might send.
The key is to think of your Zod schemas as contracts: they define exactly what your tools expect, provide helpful error messages when expectations aren't met, and transform data into the exact shape your business logic requires. With the patterns and examples in this guide, you're equipped to handle any validation scenario in your MCP servers.