Developer Guide · 2026-06-10
MCP server input validation: beyond Zod
Zod validates shape and type. It does not validate whether filename: "../../etc/passwd" is a legitimate file path, whether url: "http://169.254.169.254/latest/meta-data" should be allowed, or whether cmd: "ls; rm -rf /" belongs in your argument list. This guide covers the four layers of validation every MCP tool handler needs — and the patterns that keep adversarial LLM inputs from becoming security incidents.
The MCP threat model is different from REST APIs
Every REST API validation guide you've read assumes a human typed the input. In the MCP context, your tool handler's inputs are generated by a language model. That changes the threat model in two important ways.
First, the inputs can be adversarially crafted via prompt injection. An attacker who can influence the LLM's context — through a malicious document, a compromised MCP tool earlier in the chain, or a system prompt override — can instruct the model to pass specific values to your tool. The inputs your tool receives may look perfectly type-safe while being semantically dangerous.
Second, the LLM is not malicious itself, but it is not careful either. It will use reasonable-looking values based on prior context. If an earlier tool returned a file path that happened to contain traversal sequences, the model will pass that path to your file-reading tool without questioning it. Type-valid inputs from an unintentionally manipulated model are just as dangerous as inputs from a deliberate attacker.
The implication: Zod is layer one of four, not the whole story.
The four layers of MCP input validation
Think of input validation as a pipeline. Each layer handles a distinct class of problems:
- Layer 1 — Schema parsing: Is this the right type and shape? (Zod, TypeBox, Valibot)
- Layer 2 — Semantic allow-listing: Is this value within the safe operating envelope for this tool?
- Layer 3 — Policy context: Is this caller allowed to perform this operation on this resource?
- Layer 4 — Output validation: Does the response I'm about to return contain anything it shouldn't?
Most MCP servers implement Layer 1. The ones that receive A-grades from SkillAudit implement all four.
Layer 1: Schema parsing with Zod
Start with Zod (or TypeBox if you're using the official MCP SDK's schema utilities). Define the input schema as part of your tool registration so the MCP framework enforces it at the transport layer:
import { z } from 'zod'
const ReadFileSchema = z.object({
path: z.string().min(1).max(512),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
maxBytes: z.number().int().min(1).max(1_048_576).optional()
})
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'read_file') {
const args = ReadFileSchema.parse(request.params.arguments)
// args is now typed and shape-validated
return handleReadFile(args)
}
})
Zod protects against missing fields, wrong types, and out-of-range values. It will reject maxBytes: "all of them" and encoding: "rot13". It will not reject path: "../../etc/shadow". That's Layer 2's job.
One important Zod practice: use .parse() not .safeParse() at the top of your handler. Let the parse error propagate as a thrown exception — the MCP framework maps that to a proper protocol error automatically. Only use .safeParse() when you need to inspect the error details before deciding how to respond.
Layer 2: Semantic allow-listing
After schema parsing confirms the shape, semantic allow-listing confirms the value is within your tool's intended operating envelope. The allow-list principle is non-negotiable: enumerate what is permitted, reject everything else. Deny-lists always have holes.
Path traversal: the canonical example
Path traversal is the most common semantic validation gap in MCP servers. The path traversal vulnerability pattern is well-understood but still appears in the majority of first-time MCP server implementations.
import path from 'node:path'
const ALLOWED_ROOT = path.resolve('/app/data')
function validateFilePath(rawPath: string): string {
// 1. Resolve to absolute, collapsing all ../ and symlinks
const resolved = path.resolve(ALLOWED_ROOT, rawPath)
// 2. Check that it's still inside the allowed root
// Use path.sep to avoid prefix attacks:
// /app/data-evil starts with /app/data but is NOT inside it
if (!resolved.startsWith(ALLOWED_ROOT + path.sep) && resolved !== ALLOWED_ROOT) {
throw new Error(`Path outside allowed root: ${rawPath}`)
}
return resolved
}
// Usage in handler
const safePath = validateFilePath(args.path)
const content = await fs.readFile(safePath, 'utf-8')
The critical detail is appending path.sep before comparing. Without it, /app/data-evil/file.txt passes the startsWith('/app/data') check. This exact mistake appears in roughly 40% of path-handling tools that SkillAudit scans.
If your tool should only accept filenames (not paths), validate that too:
function validateFilename(raw: string): string {
if (/[/\\]/.test(raw)) throw new Error('Filename must not contain path separators')
if (raw.startsWith('.')) throw new Error('Hidden files not permitted')
if (raw.length > 255) throw new Error('Filename too long')
return raw
}
URL allow-listing for SSRF prevention
If your tool accepts a URL and makes an outbound HTTP request, you have an SSRF vulnerability unless you validate the URL against an explicit allow-list before the request is made.
import { URL } from 'node:url'
import dns from 'node:dns/promises'
const ALLOWED_URL_SCHEMES = new Set(['https:'])
const BLOCKED_IP_RANGES = [
/^127\./, // localhost
/^10\./, // RFC 1918
/^172\.(1[6-9]|2[0-9]|3[01])\./, // RFC 1918
/^192\.168\./, // RFC 1918
/^169\.254\./, // link-local (AWS metadata)
/^::1$/, // IPv6 localhost
/^fc00:/, // IPv6 unique local
]
async function validateUrl(rawUrl: string): Promise<string> {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
throw new Error('Invalid URL format')
}
if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) {
throw new Error(`URL scheme ${parsed.protocol} not permitted`)
}
// Resolve hostname to IP and check against blocked ranges
const addresses = await dns.resolve4(parsed.hostname).catch(() => [])
const ipv6addresses = await dns.resolve6(parsed.hostname).catch(() => [])
const allAddresses = [...addresses, ...ipv6addresses]
for (const ip of allAddresses) {
if (BLOCKED_IP_RANGES.some(r => r.test(ip))) {
throw new Error(`URL resolves to blocked IP range: ${ip}`)
}
}
if (allAddresses.length === 0) {
throw new Error('URL hostname does not resolve')
}
return rawUrl
}
Two critical points: (1) Resolve the hostname before checking — a hostname can be perfectly safe-looking while resolving to 169.254.169.254. (2) Check after every DNS resolution if your HTTP client follows redirects, since a redirect can change the target IP after your initial check. The safest approach is to disable redirects and require the caller to provide the final URL.
Command argument allow-listing
If your tool runs a subprocess — even via spawn with an argument array — validate the individual arguments. Never pass user-controlled strings as shell arguments. Always build the argument array from validated components:
const ALLOWED_COMMANDS = new Map<string, string>([
['grep', '/usr/bin/grep'],
['wc', '/usr/bin/wc'],
])
const SAFE_FLAG_PATTERN = /^-[a-zA-Z]{1,4}$/
const SAFE_PATTERN_CHARS = /^[a-zA-Z0-9 ._-]{1,200}$/
function buildSafeArgs(command: string, flags: string[], pattern: string): string[] {
const binary = ALLOWED_COMMANDS.get(command)
if (!binary) throw new Error(`Command not permitted: ${command}`)
for (const flag of flags) {
if (!SAFE_FLAG_PATTERN.test(flag)) throw new Error(`Invalid flag: ${flag}`)
}
if (!SAFE_PATTERN_CHARS.test(pattern)) throw new Error('Pattern contains invalid characters')
// Return: ['/usr/bin/grep', '-n', 'search term', '--']
// The -- prevents the pattern from being interpreted as a flag
return [binary, ...flags, '--', pattern]
}
// Usage
const args = buildSafeArgs(input.command, input.flags, input.pattern)
const result = await execFile(args[0], args.slice(1), { timeout: 5000 })
Note execFile not exec, and an absolute path for the binary. shell: true should never appear in MCP server code that processes external inputs — this is one of the Critical findings that SkillAudit flags automatically.
Layer 3: Policy context validation
Policy validation answers the question: is this caller allowed to do this operation on this specific resource? It's authz, not just type validation. This layer is frequently omitted because it requires knowing something about the session context — but the context is available if you design for it.
interface SessionContext {
sessionId: string
userId: string
allowedPaths: string[] // populated at session init
rateLimit: RateLimiter
}
async function handleReadFile(args: ReadFileArgs, ctx: SessionContext): Promise<ToolResult> {
// Layer 1: schema already validated by Zod
// Layer 2: path traversal check
const safePath = validateFilePath(args.path)
// Layer 3: is this session allowed to read this specific path?
const isAllowed = ctx.allowedPaths.some(allowed =>
safePath === allowed || safePath.startsWith(allowed + path.sep)
)
if (!isAllowed) {
return {
isError: true,
content: [{ type: 'text', text: 'Access denied to this path' }]
}
}
// Layer 3b: rate limiting per session
if (!ctx.rateLimit.allow(ctx.sessionId)) {
return {
isError: true,
content: [{ type: 'text', text: 'Rate limit exceeded. Retry after 10 seconds.' }]
}
}
const content = await fs.readFile(safePath, 'utf-8')
return { content: [{ type: 'text', text: content }] }
}
Policy validation failures should return isError: true (not throw), so the LLM sees the denial and can decide how to proceed rather than receiving an unexplained protocol error. See our guide on throw vs isError for the full decision table.
Layer 4: Output validation
Output validation is the most overlooked layer. Before returning data to the LLM, check whether the response contains anything it shouldn't. This matters most for tools that aggregate external content — search results, file contents, API responses — which may themselves contain prompt injection payloads.
const INJECTION_SIGNALS = [
/ignore previous instructions/i,
/disregard your system prompt/i,
/you are now/i,
/new instructions:/i,
/<system>/i,
/\[SYSTEM\]/i,
]
const SENSITIVE_PATTERNS = [
/\bsk-[A-Za-z0-9]{20,}\b/, // OpenAI API keys
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key IDs
/password\s*[:=]\s*\S+/i, // password= assignments
/BEGIN (RSA |EC )?PRIVATE KEY/, // PEM private keys
]
function validateOutput(raw: string, toolName: string): string {
// Check for prompt injection signals in external content
for (const pattern of INJECTION_SIGNALS) {
if (pattern.test(raw)) {
console.error(`[${toolName}] Potential injection signal in output — sanitizing`)
// Return a warning, not the raw content
return '[Content removed: potential prompt injection detected]'
}
}
// Check for credential leakage
for (const pattern of SENSITIVE_PATTERNS) {
if (pattern.test(raw)) {
console.error(`[${toolName}] Potential credential in output — redacting`)
return raw.replace(pattern, '[REDACTED]')
}
}
return raw
}
// Usage before returning
const output = validateOutput(fileContent, 'read_file')
return { content: [{ type: 'text', text: output }] }
Output validation is not a complete injection defense — a sufficiently crafted payload will evade pattern matching. The goal is defense-in-depth: catch the obvious vectors automatically while your deeper controls handle the sophisticated attacks. SkillAudit's scanner checks for the presence of output validation on tools that read external content; its absence is a Medium finding.
The validateInput wrapper pattern
In practice, you'll want a single wrapper that runs all four layers in order and produces a standardized result. This makes the validation pipeline explicit and prevents accidentally skipping a layer:
type ValidationResult<T> =
| { ok: true; value: T }
| { ok: false; error: string; code: 'SCHEMA' | 'SEMANTIC' | 'POLICY' | 'RATE_LIMIT' }
async function validateReadFileInput(
raw: unknown,
ctx: SessionContext
): Promise<ValidationResult<{ path: string; encoding: 'utf-8' | 'base64' }>> {
// Layer 1: schema
const parsed = ReadFileSchema.safeParse(raw)
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0].message, code: 'SCHEMA' }
}
// Layer 2: semantic
let safePath: string
try {
safePath = validateFilePath(parsed.data.path)
} catch (err) {
return { ok: false, error: (err as Error).message, code: 'SEMANTIC' }
}
// Layer 3: policy
const isAllowed = ctx.allowedPaths.some(p =>
safePath === p || safePath.startsWith(p + path.sep)
)
if (!isAllowed) return { ok: false, error: 'Access denied', code: 'POLICY' }
if (!ctx.rateLimit.allow(ctx.sessionId)) {
return { ok: false, error: 'Rate limit exceeded', code: 'RATE_LIMIT' }
}
return { ok: true, value: { path: safePath, encoding: parsed.data.encoding } }
}
// In handler:
const validation = await validateReadFileInput(request.params.arguments, ctx)
if (!validation.ok) {
// SCHEMA errors should throw — they're protocol violations
// SEMANTIC/POLICY/RATE_LIMIT errors should return isError: true
if (validation.code === 'SCHEMA') throw new McpError(ErrorCode.InvalidParams, validation.error)
return { isError: true, content: [{ type: 'text', text: validation.error }] }
}
The separation between throwing for schema errors and returning isError: true for semantic/policy errors is intentional. A schema error means the caller sent malformed input — that's a protocol-level failure and should be treated as an error in the call itself. A semantic or policy failure means the input was well-formed but not permitted — the LLM should see that message and potentially retry with a different value or report back to the user.
Validation completeness and SkillAudit grading
SkillAudit's scanner checks input validation across several dimensions in the Security and Permissions Hygiene axes:
| Validation gap | Finding severity | Grade impact |
|---|---|---|
| No path traversal guard on file-reading tools | High | −15 |
| URL not validated before outbound HTTP (SSRF) | High | −15 |
shell: true with user-controlled input | Critical | −25 |
| Missing schema validation (raw argument access) | High | −10 |
| No output sanitization on tools reading external content | Medium | −5 |
| Allow-list present for file paths | — | +5 |
| URL scheme + IP range validation present | — | +5 |
| Output validation present on content-aggregating tools | — | +3 |
A server that implements all four layers on all tools typically finishes 25–35 points higher than a server that only uses Zod. The difference between a C-grade and an A-grade is usually semantic validation — it's the gap between a server that runs correctly and one that runs safely.
Common mistakes to avoid
Using deny-lists instead of allow-lists. A path deny-list that blocks ../ is easily bypassed with URL-encoded sequences (%2e%2e%2f), Unicode normalization (..%c0%af), or null byte injection (file.txt%00.jpg). Allow-listing normalizes first, then checks — there's no bypass.
Validating display strings, not operative strings. If you validate the path the LLM sent but then reconstruct the path from another source, you've validated the wrong thing. Always validate the value you actually use in the operation, after any transformation.
Logging the rejected input verbatim. When validation fails, log the tool name, the field name, and the failure category — not the raw value. Logging Path outside allowed root: ../../../../etc/passwd is appropriate; logging the full argument object when it might contain credentials is not.
Treating validation as a one-time gate. If your handler fetches external content and then uses values from that content in a subsequent operation, validate those derived values too. A file's contents can contain crafted paths. A search result can contain crafted URLs. Re-validate at every trust boundary.
Putting it together: a complete validated tool handler
import { z } from 'zod'
import path from 'node:path'
import fs from 'node:fs/promises'
const ALLOWED_ROOT = path.resolve(process.env.DATA_DIR ?? '/app/data')
const ReadFileSchema = z.object({
path: z.string().min(1).max(512),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
})
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'read_file') return
// Layer 1: schema
const args = ReadFileSchema.parse(request.params.arguments)
// Layer 2: semantic — path traversal
const resolved = path.resolve(ALLOWED_ROOT, args.path)
if (!resolved.startsWith(ALLOWED_ROOT + path.sep) && resolved !== ALLOWED_ROOT) {
return { isError: true, content: [{ type: 'text', text: 'Path not permitted' }] }
}
// Layer 3: policy — rate limiting (ctx injected at session init)
const ctx = getSessionContext(request)
if (!ctx.rateLimit.allow(ctx.sessionId)) {
return { isError: true, content: [{ type: 'text', text: 'Rate limit exceeded' }] }
}
// Execute
let rawContent: string
try {
const buf = await fs.readFile(resolved)
rawContent = args.encoding === 'base64'
? buf.toString('base64')
: buf.toString('utf-8')
} catch (err) {
return { isError: true, content: [{ type: 'text', text: 'File not found or not readable' }] }
}
// Layer 4: output validation
const safe = validateOutput(rawContent, 'read_file')
return { content: [{ type: 'text', text: safe }] }
})
This pattern is 40 lines of real code. The four-layer structure is visible and auditable. Every failure path returns a useful message to the LLM rather than an opaque error. The Zod layer handles type; the path check handles traversal; the rate limiter handles abuse; the output check handles injection in file contents.
What to do next
Run SkillAudit on your MCP server before you publish it. The scanner will identify which validation layers are missing and show the exact file and line numbers where gaps exist. Most servers can add all four layers in under two hours — and the grade jump from C to A is worth it for the credibility signal alone.
If you're looking for the complete checklist of security controls the scanner evaluates, see the MCP server permissions checklist. For the broader pattern of what an A-grade server looks like, read anatomy of an A-grade MCP server.
Scan your MCP server
Paste your GitHub URL and get a graded report with exact validation gaps, line numbers, and remediation steps. First 3 scans free.
Get your free audit →