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 present, values are strings; against a Standard Schema |
context.arguments | Object of string values, count- and length-bounded, before a complete callback |
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 bound to the request's principal |
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 v4, ArkType, Valibot via toStandardJsonSchema()). Then invalid input never reaches the handler, and args is typed:
import { z } from "zod";
defineTool({
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.
#Prompt Arguments
The same applies to prompts. A declarative arguments list is checked for required presence and for the wire's own rule that values are strings; declaring arguments as a Standard Schema validates them properly:
definePrompt({
name: "release",
arguments: z.object({ version: z.string().regex(/^\d+\.\d+\.\d+$/) }),
handler: async (args) => ({
messages: [{ role: "user", content: { type: "text", text: `Release ${args.version}` } }],
}),
});A plain JSON Schema gets the same baseline the declarative list does — required presence and string values — but nothing more; only a Standard Schema enforces what it advertises.
Unlike a tool, a prompt has no isError result to hand a correction back through, so a failure is a JSON-RPC error (-32602) on every protocol version. Its message is static: a schema can quote the offending key back, and that key is client input, so the detail rides in a bounded error.data.validation instead of in the message your logs will keep.
#Tool Output
Validation runs on the way out too. A tool that declares an outputSchema promises clients a shape in tools/list, so a result that breaks it is rejected (-32602) instead of shipped: a non-error result must carry structuredContent, and that value is validated whenever the schema carries a validator. Unlike an input failure, the message is static and there is no error.data — the offending value is the server's own, and a validator's issue text can quote fragments of it back.
Two limits, with different causes:
- A plain JSON Schema
outputSchemais presence-checked only. There is no validator to call — the same limitinputSchemahas. - A transforming schema does have a validator, but Standard Schema exposes only an input → output one, so it judges the shape the schema parses from, not the output shape
tools/listpublished. The input side has no such gap: client input is exactly what a schema's input side describes. Declare output schemas as the shape your handler returns and the two coincide.
#Result Shapes
outputSchema only binds a server that published one. Three shapes are checked on every result regardless, on both eras, because a client cannot parse around them:
- a
resources/readcontentsentry must carry auriand exactly one oftext/blob— carrying both leaves a client guessing which half is authoritative, and carrying neither leaves it with nothing; - an
imageoraudiocontent block must carry bothdataandmimeType, in a tool result or inside a prompt message; - a
prompts/getmessagerolemust beuserorassistant.
A breach is a JSON-RPC error (-32603) rather than a served result, with a static message and no error.data — the offending value is your server's own output, and it is a bug in the handler, not something the caller can retry differently.
Nothing else about a result is re-validated. annotations.lastModified is not date-parsed, annotations.priority is not range-checked, and resource_link and embedded-resource fields are not inspected: those are formatting details a client renders around, and the TypeScript types already catch them at build time. If you build results dynamically in JavaScript, the types are not watching — these three checks are.
#Request _meta
event.context.mcp.meta is the client's _meta object handed over as sent — it exists so extension authors can read their own namespaced keys. The library guarantees only that it is a plain object; validate anything you read from it exactly as you would params.
#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.
defineTool({
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.