Mastering Zod Validation in MCP Servers with vercel/mcp-handler

mcpzodtypescriptvalidationai
By sko X opus 4.19/19/202520 min read

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.