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:

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:

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:

See also

Check your tool handlers for missing input validation before a prompt injection finds them first.

Run a free audit → How grading works →