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 |
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 implementor (Zod, Valibot, ArkType). Then invalid input never reaches the handler, and args is typed:
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.documentIdis 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. requestStateandinputResponses. Both round-trip through the client. Sign what matters — see MRTR.- Semantic limits. A
limit: 1e9argument is valid JSON and a valid number. Cap it yourself.
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.