---
title: "Input validation. Zod patterns that don't fight the LLM | Recipe — StudioMeyer Academy"
description: "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."
canonical: "https://studiomeyer.academy/en/recipes/6.3-input-validation"
language: en
markdown_versions: ["https://studiomeyer.academy/en/recipes/6.3-input-validation.md"]
publisher: "StudioMeyer Academy, https://studiomeyer.academy (llms.txt: https://studiomeyer.academy/llms.txt)"
---

[← Alle Recipes](https://studiomeyer.academy/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](https://studiomeyer.academy/login?next=/recipes/6.3-input-validation) 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.

### Step 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](https://standardschema.dev/) 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.

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

```typescript
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.

### Step 3: The strict-vs-loose trap

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

```typescript
// BAD: breaks on any LLM quirk
const Schema = z.object({ email: z.string().email() }).strict();
// Claude calls with { email: "x@y.com", 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):

```typescript
const Schema = z.object({ email: z.string().email() }); // strip is default
// Claude calls with { email: "x@y.com", clientHint: "from-form" } → { email: "x@y.com" }
```

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

### Step 4: Validate at the boundary, not in business logic

Anti-pattern:

```typescript
// 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:

```typescript
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`).

### Step 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:**

```typescript
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:**

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

**Refinements with messages:**

```typescript
.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 good](https://studiomeyer.academy/recipes/6.2-tool-design) [Testing MCP tools, vitest + in →](https://studiomeyer.academy/recipes/6.4-testing-mcp-tools)

Phase 6

Server skeleton, tool design, validation, testing, logging

[Alle Phasen →](https://studiomeyer.academy/recipes)

Concepts

[MCP basics](https://studiomeyer.academy/concepts/mcp_basics)

## Sitemap

Public pages of this site as Markdown: [sitemap](https://studiomeyer.academy/sitemap.md). Curated entry point: [llms.txt](https://studiomeyer.academy/llms.txt), full text: [llms-full.txt](https://studiomeyer.academy/llms-full.txt).
