Introduction
Every tool in your MCP server accepts input from an LLM, and LLMs make mistakes. They send wrong types, hallucinate parameter names, and occasionally try paths they should not access. Zod is your first line of defense — it validates input schemas automatically, but the real safety comes from the business-logic validations you add on top.
Key Concepts
- Schema validation: Zod checks types, shapes, and basic constraints at the boundary. If a parameter fails the schema, the SDK rejects the call with -32602 InvalidParams before your handler runs.
- Business validation: Checks beyond the schema — ensuring a file path is within an allowed directory, a URL points to a safe host, or a number falls within a sensible range.
- Sanitization: Transforming input to a safe form, such as resolving path traversal sequences or stripping dangerous URL schemes.
Real World Context
A developer builds a tool that reads files from a project directory. Without path validation, an LLM could request ../../etc/passwd and the server would happily serve it. In production, every MCP server that touches the filesystem or makes HTTP requests needs input sanitization.
Deep Dive
Zod Schema Basics
MCP uses Zod for input schemas. Here are the patterns you will use most often:
typescriptimport { z } from 'zod'; // Primitive types const stringSchema = z.string(); const numberSchema = z.number(); const booleanSchema = z.boolean(); // Objects with required and optional fields const querySchema = z.object({ search: z.string(), limit: z.number().int().min(1).max(100).default(10), includeArchived: z.boolean().optional() }); // Enums for constrained values const formatSchema = z.enum(['json', 'csv', 'markdown']); // Arrays with item validation const tagsSchema = z.array(z.string()).min(1).max(10);
These schemas are passed directly to inputSchema in your tool registration. The SDK validates incoming parameters automatically.
Path Traversal Prevention
File-reading tools are among the most common MCP tools, and they need path validation. The refine method lets you add custom validation logic:
typescriptinputSchema: z.object({ filePath: z.string().refine( p => !p.includes('..') && p.startsWith('/allowed/'), 'Path must be within allowed directory' ) })
This rejects any path containing .. or pointing outside /allowed/. The error message is returned to the LLM so it knows why the call failed.
For more robust validation, resolve the path and check the result:
typescriptimport path from 'path'; const ALLOWED_ROOT = '/home/user/projects'; inputSchema: z.object({ filePath: z.string().refine( (p) => { const resolved = path.resolve(ALLOWED_ROOT, p); return resolved.startsWith(ALLOWED_ROOT); }, `Path must resolve within ${ALLOWED_ROOT}` ) })
Using path.resolve catches tricks like /allowed/../etc/passwd that simple string checks might miss.
SSRF Prevention for URLs
If your tool fetches URLs, you must prevent Server-Side Request Forgery (SSRF). An LLM might ask your tool to fetch http://169.254.169.254/latest/meta-data/ — the AWS metadata endpoint — which could leak credentials.
typescriptconst BLOCKED_HOSTS = ['localhost', '127.0.0.1', '169.254.169.254', '0.0.0.0']; const ALLOWED_SCHEMES = ['http:', 'https:']; inputSchema: z.object({ url: z.string().url().refine( (u) => { const parsed = new URL(u); return ALLOWED_SCHEMES.includes(parsed.protocol) && !BLOCKED_HOSTS.includes(parsed.hostname) && !parsed.hostname.endsWith('.internal'); }, 'URL must use http/https and cannot target internal hosts' ) })
This validates the URL format, checks the scheme, and blocks requests to internal network addresses.
Validating Ranges and Patterns
For numeric inputs, always set sensible bounds. For string inputs, validate patterns and lengths:
typescriptinputSchema: z.object({ // Numeric range validation pageSize: z.number().int().min(1).max(100), temperature: z.number().min(0).max(2), // String pattern validation email: z.string().email(), slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'), // Length constraints query: z.string().min(1).max(500) })
These constraints prevent the LLM from sending absurd values like a page size of one million or an empty search query.
Common Pitfalls
- Relying only on schema validation — Zod checks types and shapes, but it cannot know your business rules. Always add
refinechecks for security-sensitive inputs like file paths and URLs. - Forgetting to validate array lengths — An LLM might send an array with thousands of items. Always use
.min()and.max()on arrays to prevent resource exhaustion. - Using permissive string types for structured data — If a parameter should be an email, URL, or enum value, use the specific Zod type instead of bare
z.string().
Best Practices
- Validate at the boundary, trust inside — Put all validation in the schema and
refinecalls. Once the handler runs, you can trust the input is safe. - Use
path.resolvefor filesystem paths — Simple string checks like!includes('..')miss edge cases. Always resolve the full path and compare against your allowed root. - Set defaults for optional parameters — Use
.default()so your handler does not need to check for undefined values.
Summary
- Zod validates input schemas automatically; failed validations return -32602 InvalidParams.
- Use
.refine()for business logic validation like path traversal and SSRF prevention. - Always validate file paths by resolving them and checking against an allowed root directory.
- Block internal hostnames and restrict URL schemes to prevent SSRF attacks.
- Set bounds on numbers and array lengths to prevent resource exhaustion.
Code Examples
import { z } from 'zod';
import path from 'path';
const ALLOWED_ROOT = '/home/user/projects';
server.registerTool('safe-read', {
description: 'Read a file safely within the project directory',
inputSchema: z.object({
filePath: z.string().refine(
(p) => {
const resolved = path.resolve(ALLOWED_ROOT, p);
return resolved.startsWith(ALLOWED_ROOT);
},
`Path must resolve within ${ALLOWED_ROOT}`
)
})
}, async ({ filePath }) => {
const resolved = path.resolve(ALLOWED_ROOT, filePath);
const content = await fs.readFile(resolved, 'utf-8');
return { content: [{ type: 'text', text: content }] };
});const BLOCKED_HOSTS = ['localhost', '127.0.0.1', '169.254.169.254'];
server.registerTool('fetch-url', {
description: 'Fetch content from a URL',
inputSchema: z.object({
url: z.string().url().refine(
(u) => {
const parsed = new URL(u);
return ['http:', 'https:'].includes(parsed.protocol)
&& !BLOCKED_HOSTS.includes(parsed.hostname);
},
'URL must use http/https and cannot target internal hosts'
),
format: z.enum(['text', 'json']).default('text')
})
}, async ({ url, format }) => {
const response = await fetch(url);
const data = format === 'json'
? JSON.stringify(await response.json(), null, 2)
: await response.text();
return { content: [{ type: 'text', text: data }] };
});