teachyou.ai academy
← All posts
MCPtool safetyJSON Schemaagent securityTypeScript

Input Validation for MCP Tools

Pramod Dutta · Jun 27, 2026 · 12 min read

MCP tool input validation is the layer that decides whether an argument object coming from a language model is safe to execute, or whether it needs to bounce back with a clear error. Every MCP tool exposes a JSON Schema in its definition, but a schema alone only checks shape, not meaning. A field can pass "type: string" and still be an empty string, a path outside your project, or a number formatted as text. Without a second layer of runtime validation, that gap becomes the place where bugs, crashes, and security holes live.

This matters more for MCP than for a typical REST API because the caller is not a human filling out a form. It is a model generating arguments from a natural-language plan, sometimes correctly, sometimes with a typo, sometimes with a value hallucinated because the schema description was ambiguous. Good input validation is what turns those mistakes into a recoverable error message the model can read and retry, instead of a stack trace or, worse, a silently wrong action like deleting the wrong file.

Why schema-only validation is not enough

The MCP tool definition includes an inputSchema field, typically JSON Schema draft 2020-12. Most SDKs (the TypeScript SDK, the Python SDK, FastMCP) will reject a call before it reaches your handler if the arguments do not match that schema's basic type constraints. That catches the obvious cases: a missing required field, a string where a number was expected, an object with an unknown property if additionalProperties: false is set.

What JSON Schema alone will not catch:

  • A limit field typed as integer with no minimum/maximum, so the model can pass 999999999 and blow your database query.
  • A path field typed as string that resolves to /etc/passwd or escapes your working directory with ../../.
  • An email field that is syntactically a string but not a valid email.
  • Cross-field constraints, like startDate needing to be before endDate.
  • Business rules, like a discountPercent that must be zero for a user who is not an admin.

These need runtime validation inside the tool handler, on top of the schema. Treat the schema as documentation-plus-first-filter for the model, and treat the runtime check as the actual security boundary.

Setting up a validation layer with Zod (TypeScript)

If you are building an MCP server with the TypeScript SDK, Zod is the natural fit because the SDK's high-level server.tool() API already accepts Zod schemas and uses them both to generate the JSON Schema sent to the client and to parse incoming arguments.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({
  name: "file-tools",
  version: "1.0.0",
});

const readFileArgs = z.object({
  path: z
    .string()
    .min(1, "path cannot be empty")
    .refine((p) => !p.includes(".."), "path traversal is not allowed")
    .refine((p) => p.startsWith("/workspace/"), "path must be inside /workspace"),
  encoding: z.enum(["utf-8", "base64"]).default("utf-8"),
  maxBytes: z.number().int().positive().max(5_000_000).default(1_000_000),
});

server.tool(
  "read_file",
  "Read a file from the workspace directory",
  readFileArgs.shape,
  async ({ path, encoding, maxBytes }) => {
    // path, encoding, and maxBytes are already validated and typed here
    const data = await readWorkspaceFile(path, { encoding, maxBytes });
    return {
      content: [{ type: "text", text: data }],
    };
  }
);

Two things are doing real work in that example. First, readFileArgs.shape is what the SDK converts into the JSON Schema the model sees, so the model gets an accurate contract, including the enum for encoding and the max on maxBytes. Second, the .refine() calls run at request time, after the type check, and reject a call before your file-reading code ever touches the disk with an unsafe path.

If a tool has parameters that depend on each other, use z.object().refine() at the object level instead of per-field:

const scheduleArgs = z
  .object({
    startDate: z.string().datetime(),
    endDate: z.string().datetime(),
  })
  .refine((data) => new Date(data.startDate) < new Date(data.endDate), {
    message: "startDate must be before endDate",
    path: ["endDate"],
  });

The path option tells the model exactly which field to fix, which matters a lot for self-correction (more on that below).

Setting up a validation layer with Pydantic (Python)

For Python MCP servers built with the official SDK or FastMCP, Pydantic plays the same role Zod plays in TypeScript. FastMCP infers the schema from type hints and Pydantic models directly.

from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field, field_validator
import re

mcp = FastMCP("file-tools")

class ReadFileArgs(BaseModel):
    path: str = Field(..., min_length=1, description="Path relative to /workspace")
    encoding: str = Field("utf-8", pattern="^(utf-8|base64)$")
    max_bytes: int = Field(1_000_000, gt=0, le=5_000_000)

    @field_validator("path")
    @classmethod
    def no_traversal(cls, v: str) -> str:
        if ".." in v:
            raise ValueError("path traversal is not allowed")
        if not v.startswith("/workspace/"):
            raise ValueError("path must be inside /workspace")
        return v

@mcp.tool()
def read_file(args: ReadFileArgs) -> str:
    """Read a file from the workspace directory."""
    return read_workspace_file(args.path, args.encoding, args.max_bytes)

Pydantic raises a ValidationError automatically when the model construction fails, and FastMCP turns that into a tool error result that gets sent back to the client with the field-level messages intact. You do not need to write your own try/except around the validators for the common case, though wrapping the tool body in a try/except is still worth it for errors that happen after validation, like the file genuinely not existing.

Designing schemas the model can actually follow

MCP tool input validation is only half the job. The other half is writing a schema and description precise enough that the model rarely needs to be corrected in the first place. A few concrete rules:

  • Use `enum` instead of free-text string whenever the set of valid values is fixed. A status field with enum: ["open", "closed", "pending"] cannot be misspelled by the model; a free-text status field will eventually get "Open" or "opened".
  • Set `minimum`, `maximum`, `minLength`, `maxLength` in the schema, not just in your handler. Some clients show these constraints to the model in the tool description, which reduces bad calls before they happen.
  • Write descriptions that state the unit and format. "timeout": { "type": "integer", "description": "Timeout in milliseconds, 100 to 30000" } is far less likely to be misused than "description": "Timeout".
  • Mark fields `required` deliberately. Every optional field should have a sane default in your handler; do not rely on the model always supplying it.
  • Keep the top-level object flat where possible. Deeply nested objects are where LLMs most often produce malformed JSON. If you need nesting, keep it to one level and validate the nested shape explicitly.
  • Avoid `additionalProperties: true` on objects that map to internal logic. Leaving it open invites the model to invent fields that silently do nothing, or worse, get picked up by a loosely typed handler downstream.

Returning errors the model can recover from

A validation failure inside a tool handler should not be a thrown exception that crashes the server process. In both the TypeScript and Python SDKs, a tool result can carry isError: true with a text explanation, and the MCP client feeds that text back to the model as the tool's output. Write that text the way you would write a message to a junior developer: state what was wrong and what a valid value looks like.

server.tool(
  "create_reminder",
  "Create a reminder for the user",
  reminderArgs.shape,
  async (args) => {
    const parsed = reminderArgs.safeParse(args);
    if (!parsed.success) {
      const issues = parsed.error.issues
        .map((i) => `${i.path.join(".")}: ${i.message}`)
        .join("; ");
      return {
        isError: true,
        content: [{ type: "text", text: `Invalid arguments: ${issues}` }],
      };
    }
    // proceed with parsed.data
  }
);

Note the use of safeParse instead of parse here. parse throws on failure, which forces you to wrap every call in try/catch. safeParse returns a discriminated union ({ success: true, data } or { success: false, error }), which is easier to branch on and keeps validation errors from turning into unhandled exceptions that take down the whole tool call with a generic "internal error" the model cannot act on.

The same principle applies in Pydantic: catch ValidationError explicitly if you are not relying on FastMCP's automatic handling, and turn error.errors() into a compact, field-by-field message rather than dumping the raw Pydantic traceback.

from pydantic import ValidationError

def create_reminder(raw_args: dict):
    try:
        args = ReminderArgs.model_validate(raw_args)
    except ValidationError as e:
        messages = [f"{err['loc'][0]}: {err['msg']}" for err in e.errors()]
        return {"isError": True, "content": [{"type": "text", "text": "; ".join(messages)}]}
    # proceed with args

Validating beyond the type system: security-sensitive tools

Some MCP tools carry more risk than others because they touch the filesystem, run shell commands, or call external APIs with credentials. For these, treat input validation as a security control, not just a correctness check.

Path arguments. Always resolve to an absolute path and check it is contained within an allowed root directory before touching the filesystem. Do not just check the string for ".."; a symlink or an absolute path like /etc/passwd bypasses a naive substring check.

import path from "node:path";

function assertInsideWorkspace(userPath: string, root: string) {
  const resolved = path.resolve(root, userPath);
  if (!resolved.startsWith(path.resolve(root) + path.sep)) {
    throw new Error(`path escapes workspace root: ${userPath}`);
  }
  return resolved;
}

Command arguments. If a tool builds a shell command from model-supplied arguments, never string-concatenate them into a shell string. Pass arguments as an array to a spawn call that does not invoke a shell, and validate each argument against an allowlist pattern (for example, only alphanumeric plus a fixed set of flags) before it reaches the process call.

Numeric limits. Any field that controls how much work a tool does (limit, depth, pageSize, iterations) needs a hard upper bound enforced in code, independent of what the schema advertises. A model occasionally ignores schema constraints, and a malicious or buggy client will ignore them on purpose.

Structured output leaking into input. If one tool's output feeds into another tool's input in a multi-step agent loop, validate that value again on the way in. Do not assume that because your own server produced a value earlier, it is safe now; the value passed through the model's context window and could have been altered, truncated, or reformatted along the way.

Testing your validation layer

Treat the validation logic as testable code, because it is the part of your MCP server most likely to be exercised by adversarial or simply confused input. A minimal test suite for a tool should include:

  • A valid-arguments case that confirms the happy path works.
  • A missing-required-field case that confirms you get a clear isError result, not a crash.
  • A boundary case for every minimum/maximum (one value inside, one value just outside).
  • A malicious-path case (../../etc/passwd, absolute paths, symlink escape) if the tool touches the filesystem.
  • An extra-fields case if additionalProperties matters to your logic.
import { describe, it, expect } from "vitest";

describe("read_file validation", () => {
  it("rejects path traversal", () => {
    const result = readFileArgs.safeParse({ path: "/workspace/../etc/passwd" });
    expect(result.success).toBe(false);
  });

  it("rejects maxBytes above the ceiling", () => {
    const result = readFileArgs.safeParse({ path: "/workspace/a.txt", maxBytes: 10_000_000 });
    expect(result.success).toBe(false);
  });

  it("accepts a well-formed request", () => {
    const result = readFileArgs.safeParse({ path: "/workspace/a.txt" });
    expect(result.success).toBe(true);
  });
});

Run these tests in CI on every change to a tool's schema. Schema drift, where the JSON Schema sent to the client no longer matches what the handler actually accepts, is one of the most common causes of an MCP tool that "used to work" suddenly rejecting every call from the model.

Common mistakes

  • Trusting the client's schema enforcement completely. Not every MCP client validates arguments against the schema before sending them; some pass whatever the model produced straight through. Your handler is the last line of defense, always.
  • Throwing raw exceptions instead of returning `isError` results. An unhandled exception often surfaces to the model as a generic "tool call failed" with no detail, which makes self-correction much harder than a clear validation message would.
  • Validating types but not ranges. z.number() alone accepts negative numbers, NaN-adjacent values, and astronomically large numbers. Pair every numeric field with realistic bounds.
  • Silently coercing bad input into a default instead of rejecting it. If a model sends limit: -5 and your code quietly clamps it to 1, the model never learns its request was malformed, and next time it may send something worse.
  • Skipping validation on "trusted" internal chains. In a multi-agent setup where one MCP server calls another, arguments still originate from model reasoning at some point in the chain. Validate at every boundary, not just the outermost one.

FAQ

Does the MCP inputSchema replace the need for runtime validation? No. The schema is a contract shared with the client and model; it filters obviously malformed calls before they reach your handler in SDKs that enforce it, but it cannot check business rules, cross-field constraints, or security boundaries like path containment. Runtime validation with Zod, Pydantic, or hand-written checks is still required.

What should a tool return when validation fails? A tool result with isError: true and a content array containing a short, specific text message naming the field and the constraint that failed. Avoid raw stack traces or generic "invalid input" messages; the model uses this text to retry with corrected arguments.

Should I use `additionalProperties: false` on every tool schema? For most tools, yes. It prevents a model from inventing extra fields that your handler silently ignores, which is a common source of confusing behavior where the model believes it configured something that had no effect.

How strict should numeric bounds be? As strict as your system can actually handle safely, not just what "seems reasonable." If a limit field above 100 would cause a slow query or a large response that blows the context window, cap it at 100 in both the schema and the runtime check, and say so in the field description.

Can I rely on the LLM to send correct arguments if my descriptions are good? Good descriptions reduce the error rate but never eliminate it. Models occasionally misformat JSON, hallucinate a plausible-looking value, or carry over a stale value from earlier context. Always validate at runtime regardless of how well-specified the schema is.

Is Zod or Pydantic required, or can I hand-roll validation? Neither is required. Hand-rolled if checks work fine for a one-off tool with two fields. The value of a schema library grows with the number of fields, the amount of cross-field logic, and the need to keep the JSON Schema sent to the client in sync with the runtime checks, which both Zod and Pydantic handle for you automatically.