Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle Recipes
Phase 6 · Build Your Own MCP Server·5 steps

Input validation. Zod patterns that don't fight the LLM

Why every MCP tool needs runtime validation, the Zod patterns that work, the strictness traps that break LLM calls, and the error responses that let the LLM recover.

5 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

Input validation. Zod patterns that don't fight the LLM

JSON Schema in the tool definition teaches the LLM how to call. Zod at the entry point catches the bad calls that slip through. You need both. This recipe shows the patterns that work and the strictness traps that break tools in production.

Schritt 1: Why JSON Schema isn't enough

The MCP inputSchema is JSON Schema, and Claude validates against it before calling, most of the time. But:

  • Claude sometimes drops fields when the model is reasoning under pressure
  • Claude sometimes adds fields it thinks would help (additionalProperties: false mostly stops this, but not always)
  • Claude sometimes coerces types ("1" instead of 1)
  • An MCP server might receive calls from any client, not just Claude. Cursor, Codex, custom agents all behave slightly differently

Runtime validation is the boundary. Zod is the cleanest TypeScript-native option.

Aside on Standard Schema: The MCP SDK's 1.x line ships with Zod-shaped helpers in many of its examples. The roadmap for SDK 2.x adopts the Standard Schema interface, which means Zod, Valibot, ArkType and a few others all become drop-in. If you're starting today, use Zod, it's still the path of least resistance and will keep working under v2 via the Standard Schema adapter. If you specifically want a smaller bundle (Edge / Workers), Valibot is the most popular alternative.

Schritt 2: One Zod schema per tool, defined next to the tool

import { z } from 'zod';

const CreateContactInput = z.object({
  email: z.string().email().toLowerCase().trim(),
  name: z.string().min(1).max(120).trim(),
  tags: z.array(z.string()).max(20).default([]),
  source: z.enum(['form', 'import', 'api']).default('api'),
});

type CreateContactInput = z.infer<typeof CreateContactInput>;

Three things to notice:

  • .toLowerCase() + .trim() on email. Zod normalizes before validating. No more "two contacts because one had a trailing space".
  • .default([]) on tags, the LLM can omit it; you still get an array.
  • z.infer<>, your TypeScript type is the schema. One source of truth.

Schritt 3: The strict-vs-loose trap

The first instinct is .strict() (reject any unknown field). Don't. Here's why:

// BAD: breaks on any LLM quirk
const Schema = z.object({ email: z.string().email() }).strict();
// Claude calls with { email: "[email protected]", clientHint: "from-form" } → ZodError

The LLM sometimes adds fields it thinks would help. .strict() rejects them; the LLM sees the error and retries with the same call. Loop.

Use the default .strip() mode instead (silently drops unknown fields):

const Schema = z.object({ email: z.string().email() }); // strip is default
// Claude calls with { email: "[email protected]", clientHint: "from-form" } → { email: "[email protected]" }

If you genuinely need strict for security (e.g., admin endpoints), use .passthrough() and log unknowns instead, at least the call still works.

Schritt 4: Validate at the boundary, not in business logic

Anti-pattern:

// BAD: validation scattered across handlers
async function createContact(args: any) {
  if (!args.email) throw new Error('email required');
  if (!args.email.includes('@')) throw new Error('bad email');
  // ...
}

Pattern:

case 'crm_create_contact': {
  const parsed = CreateContactInput.safeParse(req.params.arguments);
  if (!parsed.success) {
    return toErrorResponse('INVALID_INPUT',
      'Input validation failed',
      formatZodIssues(parsed.error.issues),
    );
  }
  return await createContact(parsed.data); // typed, normalized, safe
}

function formatZodIssues(issues: z.ZodIssue[]): string {
  return issues
    .map((i) => `field "${i.path.join('.')}", ${i.message}`)
    .join('; ');
}

The handler gets a fully-typed, normalized object. Business logic never touches any. The error response includes a hint the LLM can act on (field "email", Invalid email).

Schritt 5: Verify

Run academy_validate_step. The validator confirms package.json has @modelcontextprotocol/sdk plus a bin or main entry. Zod is a peer concern, the validator only checks the MCP plumbing.

Patterns that pay off long-term

Discriminated unions for tool variants:

const Search = z.discriminatedUnion('mode', [
  z.object({ mode: z.literal('email'), email: z.string().email() }),
  z.object({ mode: z.literal('id'),    id: z.string().uuid() }),
  z.object({ mode: z.literal('phone'), phone: z.string().regex(/^\+\d{6,}$/) }),
]);

The LLM reads "mode is one of: email, id, phone" and picks correctly. One tool, three input shapes, zero overlap.

Coerce, don't reject:

limit: z.coerce.number().int().min(1).max(100).default(20)
// Claude passes "20" (string) → 20 (number). No retry, no error.

Refinements with messages:

.refine((v) => v.email || v.contactId, {
  message: 'Either email or contactId is required (mutually exclusive).',
})

The message is what the LLM sees. Make it actionable.

Anti-patterns

  • z.any() anywhere in tool inputs, defeats the point
  • Throwing instead of returning typed error responses, kills LLM recovery
  • Different schemas in JSON Schema vs Zod, one source of drift, two debugging sessions
  • transform() that mutates the input shape silently. Claude won't know what came back

The goal: the LLM passes anything reasonable, your tool either does the work or returns an error with a hint that fixes the next call.

Tool design, what makes a goodTesting MCP tools, vitest + in