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

InputChecked
JSON-RPC envelopejsonrpc, method, id presence and types (-32600)
paramsObject shape; required strings and objects per method (-32602)
Tool argumentsAgainst inputSchema when it is a Standard Schema
Prompt argumentsRequired present, values are strings; against a Standard Schema
context.argumentsObject of string values, count- and length-bounded, before a complete callback
Resource uriParseable; matched against resources then URI templates
Pagination cursorOpaque 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-TypeSee 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 versionInvalid arguments produce
≤2025-06-18A JSON-RPC error
≥2025-11-25A 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 outputSchema is presence-checked only. There is no validator to call — the same limit inputSchema has.
  • 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/list published. 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.
Read more in Structured output.

#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/read contents entry must carry a uri and exactly one of text / blob — carrying both leaves a client guessing which half is authoritative, and carrying neither leaves it with nothing;
  • an image or audio content block must carry both data and mimeType, in a tool result or inside a prompt message;
  • a prompts/get message role must be user or assistant.

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.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.
  • Semantic limits. A limit: 1e9 argument 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.

h3-mcp  MCP servers, built on H3.