
# Input Validation

> Validated at the boundary, not in your handlers.

Everything a client sends is validated before it reaches a handler — JSON-RPC shape, parameter types, header agreement, cursors, URIs, session ids. The rule the library follows internally is that **no unvalidated value crosses into a handler**, so what your code receives has already been type-checked.

## What Is Checked for You

| Input                           | Checked                                                             |
| ------------------------------- | ------------------------------------------------------------------- |
| JSON-RPC envelope               | `jsonrpc`, `method`, `id` presence and types (`-32600`)             |
| `params`                        | Object shape; required strings and objects per method (`-32602`)    |
| Tool `arguments`                | Against `inputSchema` when it is a Standard Schema                  |
| Prompt `arguments`              | Required arguments present                                          |
| Resource `uri`                  | Parseable; matched against resources then URI templates             |
| Pagination `cursor`             | Opaque format validated, never trusted as an offset                 |
| `_meta` (modern)                | Required protocol version and client capabilities; log level; trace |
| Mirrored headers (modern)       | Equality with the body (`-32020`)                                   |
| `Mcp-Session-Id` (legacy)       | Length cap, character allowlist, then store lookup                  |
| `MCP-Protocol-Version` (legacy) | Known version, before method dispatch                               |
| Body size and `Content-Type`    | See [limits](/security/limits)                                      |

Errors carry static messages and never echo the offending value back — an error response is not a reconnaissance tool.

## Tool Arguments

Runtime validation of tool arguments happens when `inputSchema` is a [Standard Schema](https://standardschema.dev/json-schema) implementor (Zod, Valibot, ArkType). Then invalid input never reaches the handler, and `args` is typed:

```ts
import { z } from "zod";

defineMcpTool({
  name: "create-user",
  inputSchema: z.object({
    email: z.string().email(),
    role: z.enum(["admin", "member"]).default("member"),
  }),
  handler: async (args) => {
    // args.email is a valid email; args.role is one of two strings
    return { content: [{ type: "text", text: `ok` }] };
  },
});
```

How a failure is reported is version-aware, per SEP-1303:

| Client protocol version | Invalid arguments produce                                           |
| ----------------------- | ------------------------------------------------------------------- |
| `≤2025-06-18`           | A JSON-RPC error                                                    |
| `≥2025-11-25`           | A tool result with `isError: true`, so the model can correct itself |

> [!IMPORTANT]
> A plain JSON Schema `inputSchema` is **advertised but not enforced** — there is no validator to run. With plain JSON Schema, `args` arrives as whatever the client sent. Either use a Standard Schema implementor, or validate inside the handler before you use anything.

## Still Your Job

Validation is about shape. These are about meaning, and the library cannot do them for you:

- **Authorization.** That `args.documentId` is a well-formed UUID says nothing about whether this caller may read it.
- **Injection.** Never interpolate an argument, a template parameter, or a completion value into SQL, a shell command, a filesystem path, or an outbound URL. Parameterize, or resolve against an allowlist.
- **Resource URIs from templates.** `app://files/{path}` will happily match `../../etc/passwd`. Normalize and confine the extracted value to the directory you intended.
- **`requestState` and `inputResponses`.** Both round-trip through the client. Sign what matters — see [MRTR](/guide/modern/mrtr).
- **Semantic limits.** A `limit: 1e9` argument is valid JSON and a valid number. Cap it yourself.

```ts
defineMcpTool({
  name: "read-doc",
  inputSchema: z.object({ id: z.string().uuid(), limit: z.number().int().min(1).max(100) }),
  handler: async (args, event) => {
    const tenant = event.context.tenant; // set during auth
    const doc = await db.getDoc({ id: args.id, tenant }); // scoped, parameterized
    if (!doc) return { isError: true, content: [{ type: "text", text: "Not found" }] };
    return { content: [{ type: "text", text: doc.body.slice(0, args.limit) }] };
  },
});
```

Notice the last two lines of defense: the query is scoped to the caller's tenant, and a missing document is reported the same way as one that does not exist — so the tool cannot be used to probe for ids that do.
