Input validation for MCP tools: bounded schemas that fail closed
Every argument an MCP client sends to your tool is attacker-influenced. The values come from a language model, and that model can be steered by prompt injection in any content it reads. So treat tool inputs the way you treat an unauthenticated HTTP request body: never trust the shape, the size, or the contents. The good news is that MCP tools already carry a JSON Schema, so a bounded, typed schema plus a few sanitizers gets you most of the way there.
Start with a bounded typed schema
The inputSchema you publish is documentation for the model and a contract for your server. Make that contract tight. Every string should have a maxLength, every number a minimum and maximum, every enum-like field an explicit enum, and the object itself should set additionalProperties: false so unexpected keys are rejected rather than ignored.
Using a runtime validator like Zod keeps the published schema and the enforced schema in one place, so they cannot drift apart.
import { z } from "zod";
const SearchArgs = z.object({
query: z.string().trim().min(1).max(200),
limit: z.number().int().min(1).max(50).default(10),
sort: z.enum(["relevance", "newest", "oldest"]).default("relevance"),
}).strict(); // reject unknown keys
function handleSearch(raw: unknown) {
const args = SearchArgs.parse(raw); // throws on bad input
// args is now fully typed and bounded
}
The .strict() call is the part people forget. Without it, an extra field like {"query":"x","__proto__":{...}} or a smuggled path argument sails straight through into your handler.
Why max lengths and enums actually matter
Bounds are not cosmetic. They close real classes of abuse before your logic ever runs.
- Max lengths stop resource exhaustion. A 50 MB
querystring can blow up memory, regex backtracking, or a downstream API bill. A model under injection will happily send it. - Numeric bounds stop pagination and fan-out abuse. Without a cap,
limit: 10000000turns one tool call into a denial-of-service against your own database. - Enums beat free-text flags. If a field can only ever be a known value, an allowlist enum makes invalid input structurally impossible instead of something you check for later.
- Tight types shrink the injection surface. A field typed as
z.number()can never carry a shell metacharacter or a path traversal sequence.
Sanitize paths so they cannot escape
Any tool that reads or writes a file is one bad argument away from path traversal. Validating the string is not enough, because ../../etc/passwd and a symlink both pass a naive check. Resolve the path to an absolute form, then confirm it still lives inside the directory you allow.
import path from "node:path";
const ROOT = path.resolve("/srv/app/data");
function safePath(input: string): string {
if (input.includes("\0")) throw new Error("null byte");
const resolved = path.resolve(ROOT, input);
// ensure the resolved path is inside ROOT, with a trailing sep
// so /srv/app/data-secret does not match /srv/app/data
if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) {
throw new Error("path escapes root");
}
return resolved;
}
Reject null bytes early, never concatenate the user string onto a base path, and compare against ROOT + path.sep so a sibling directory with a shared prefix cannot sneak in.
Sanitize URLs before you fetch them
A URL argument is the most dangerous string a tool can take, because fetching it can turn your server into a proxy into your private network and cloud metadata. Parse the URL, enforce an https-only scheme allowlist, and block private and link-local targets. Validation alone does not solve SSRF, so pair it with a hardened fetch that re-resolves DNS and refuses redirects.
function safeUrl(raw: string): URL {
const u = new URL(raw); // throws on malformed input
if (u.protocol !== "https:") throw new Error("https only");
const host = u.hostname.toLowerCase();
if (host === "localhost" || host.endsWith(".internal")) {
throw new Error("blocked host");
}
return u; // re-resolve + re-check IP, no redirects, before fetch
}
For the full outbound story, including DNS rebinding and the metadata endpoint, see SSRF protection.
Fail closed, and return a clean error
When validation fails, stop. Do not coerce, do not "fix" the input, do not fall back to a default that runs the operation anyway. Failing closed means the only paths out of your validator are a fully valid value or a rejection.
- Validate at the very top of the handler, before any side effect.
- Return an MCP error result, not a thrown stack trace, so the model gets a usable signal and your internals stay private.
- Keep messages specific but not leaky: say which field was invalid, not what your filesystem layout is.
- Log the rejection on the server so you can spot probing.
try {
const args = SearchArgs.parse(raw);
return await runSearch(args);
} catch (err) {
return {
isError: true,
content: [{ type: "text", text: "Invalid arguments: " + summarize(err) }],
};
}
Check what you already ship
Most MCP servers in the wild publish loose schemas with unbounded strings and no enums. Before hardening tool by tool, get a baseline. The free mcp-audit tool scans your config for servers running without auth, over cleartext, and other issues that travel with weak validation, all locally and with zero dependencies.
Validated tool inputs, ready to ship
MCP Forge Kit ships every tool with a strict bounded Zod schema, path and URL sanitizers, and fail-closed error handling already wired in, plus tests and CI.
Get MCP Forge Kit, €39Related: Write a secure tool · SSRF protection · Security checklist