Chapter 6 of 7 · 15 min read
Validating tool arguments and results
On this page
A tool definition in MCP carries an inputSchema — a JSON Schema, meaning a declarative description of which fields the argument object may contain, what type each one is, and what bounds apply. It arrives in the tools/list response your client receives at connection time, milliseconds before a run starts, which makes it a machine-checkable contract rather than documentation. Most clients never read it. They pass the schemas through to the model so it can choose arguments, and then send whatever comes back.
When a server tightens a constraint, renames a tool, or removes a field from a result, the schema in memory already says so, and nothing consults it; the failure instead arrives from the far side of the wire, mid-run, as text in a model's context where it cannot be counted, alerted on or assigned.
The call that was knowable before it left
A run has already done real work: eighteen seconds in, three tool calls deep, about 4,200 tokens of context accumulated, the agent issues the call it has issued successfully for six months.
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"catalog_search","arguments":{"query":"winter jacket","limit":500}}}
{"jsonrpc":"2.0","id":7,"error":{"code":-32602,
"message":"Invalid arguments for tool catalog_search",
"data":{"path":"/limit","keyword":"maximum","limit":100}}}The server tightened limit from any positive integer to a maximum of 100, which is a defensible change on their side and probably a load-shedding decision. What happens next on your side is the part worth designing. The error text goes into the model's context as the result of the call, the model reads a message about invalid arguments, and its most common response is to try again — sometimes with limit: 500 unchanged because nothing told it the value was the problem, sometimes with a guess. Two or three round trips later the run ends, having spent perhaps 1,400 extra tokens and nine seconds to arrive at a failure that was knowable before the first request left the process.
Knowable is the operative word: the server published limit's new maximum in the same tools/list response the client used to build its bindings.
Why that failure is invisible to everything a team normally watches is a chain worth following link by link, because each link is a design choice rather than an accident. The rejection arrives as a JSON-RPC error, which the client turns into the result of a tool call. A tool result is text placed in the model's context. Nothing along that path raises an exception in a process anybody owns, so no error counter increments, no stack trace is produced, and nothing carries a service name an alert could be attached to. The run then ends by producing an answer, because producing an answer from the material in front of it is what a model does, and an answer is what the metrics record. The conclusion is not that the monitoring was configured badly. It is that the failure never took a form monitoring can see, and every guard in this chapter works by moving the same event into a form that does — an exception, inside your process, with a name.
The nine seconds are worth attributing correctly while the numbers are in view, because they suggest the wrong remedy otherwise. Two or three round trips at the hosted endpoint's median would be under half a second in total, so the network is a rounding error here. The rest is the model reading each rejection and generating another attempt, which means the cost of this failure scales with the number of retries the model makes rather than with the latency of the server, and shaving latency does nothing to it.
Validate against the schema the server just handed you
Compiling those schemas with a validator such as Ajv at connect time and running arguments through them before sending turns a mid-run remote rejection into a local, immediate, attributable failure:
import Ajv, { type ValidateFunction } from "ajv";
const ajv = new Ajv({ allErrors: true, strict: false });
const validators = new Map<string, ValidateFunction>();
export async function bindTools(client: Client): Promise<void> {
validators.clear();
for (const tool of (await client.listTools()).tools) {
validators.set(tool.name, ajv.compile(tool.inputSchema));
}
}
export async function callTool(
client: Client,
name: string,
args: Record<string, unknown>,
): Promise<CallToolResult> {
const validate = validators.get(name);
if (!validate) {
throw new ContractError(`tool ${name} is not advertised by this server`);
}
if (!validate(args)) {
throw new ContractError(
`arguments rejected for ${name}: ${ajv.errorsText(validate.errors)}`,
);
}
return client.callTool({ name, arguments: args });
}The failure now reads arguments rejected for catalog_search: data/limit must be <= 100, it is raised inside your process with your stack trace attached, and it happens whether or not the network is reachable. That is a defect with a location, and it can be counted, alerted on and assigned, which is what makes it different in kind from an error that only exists as text in a model's context.
Rebinding on notifications/tools/list_changed keeps the validators honest for servers that declare listChanged, and re-running bindTools on reconnection covers the rest. The cost is one tools/list per connection, which for a fourteen-tool server is a few kilobytes and a single round trip.
Who chose the offending value decides what to do with the error, and this is the distinction that keeps a good guard from becoming an annoying one. If limit: 500 is hard-coded in your client, no retry can help and the error should reach a human. If the model chose the value — it asked for 500 results because a user asked for "all of them" — then the constraint is information the model can act on, and returning a structured correction saying the maximum is 100 lets the run continue honestly, using one round trip and no invalid request. Both paths start from the same local rejection, so the guard is not making a policy decision; it is making the policy decision possible.
Renamed tools fail at bind time or not at all
A rename is the same class of drift arriving at a different moment. search_documents becoming documents_search, the release that broke every run in chapter 3's argument for pinning, produces -32602 Unknown tool on first use, which in an agent may be forty minutes into a batch job or in front of a customer. The tool names your system depends on are known statically — they appear in bindings, in prompt templates, in routing tables — so they can be checked once at startup against the advertised list:
const REQUIRED = ["catalog_search", "catalog_get_item", "pricing_quote"] as const;
const advertised = new Set([...validators.keys()]);
const missing = REQUIRED.filter((n) => !advertised.has(n));
if (missing.length > 0) {
throw new ContractError(`server is missing required tools: ${missing.join(", ")}`);
}Placed in a readiness probe, the check an orchestrator runs before it will route traffic to a new replica, that turns a rename into a deployment that refuses to become healthy, so the previous replica keeps serving. Placed in a deploy-time smoke test, it turns the rename into a red build. Placed nowhere, it turns into an unexplained morning of failed runs. The check is worth applying to optional tools too, at warning level, because a tool disappearing from a server you use for an occasional code path is a fact worth knowing before that path runs.
The inbound side breaks nothing on the wire
Everything above guards what leaves the process. Drift in the other direction is quieter, because a result that is missing a field is a perfectly valid JSON-RPC response — the wire is satisfied, the SDK is satisfied, and the type annotation in your code is a compile-time fiction that describes what the server used to return.
{"jsonrpc":"2.0","id":9,"result":{"content":[{"type":"text","text":"..."}],
"structuredContent":{"sku":"JKT-114","currency":"EUR","list_price":189.0}}}If your code reads result.structuredContent.unit_price because that is what the field was called last month, JavaScript hands back undefined, arithmetic on it produces NaN, and a template renders "Your total is €NaN" or, worse, a formatter coerces it to zero and the customer is told the item is free. Nothing threw. The run completed. The metrics show a success.
Guarding this means asserting the shape you expected, at the boundary, on every result:
import { z } from "zod";
const Quote = z.object({
sku: z.string(),
currency: z.enum(["EUR", "USD", "GBP"]),
unit_price: z.number().positive(),
});
const raw = await callTool(client, "pricing_quote", { sku, currency });
const parsed = Quote.safeParse(raw.structuredContent);
if (!parsed.success) {
throw new ContractError(`pricing_quote result drifted: ${parsed.error.message}`);
}Servers that adopt the outputSchema field, available since the 2025-06-18 protocol revision, publish the shape of structuredContent in the same way they publish inputSchema, and where a server provides one it is worth validating against both: theirs catches the server contradicting itself, yours catches the server changing what you depended on. The two are not redundant, because a server can rename a field, update its own outputSchema correctly, and be entirely self-consistent while breaking you.
| Drift | Wire-level symptom | Caught by | Without the guard |
|---|---|---|---|
| Argument constraint tightened | -32602 mid-run | Pre-send validation | Model retries, run fails after extra tokens |
Argument removed, additionalProperties: false | -32602 mid-run | Pre-send validation | As above, plus a lost capability nobody notices |
| Tool renamed or removed | -32602 Unknown tool on first use | Bind-time assertion | Fails whenever that path first runs |
| Result field removed | None — valid response | Response schema check | undefined flows downstream, NaN or a wrong value |
| Enum gains a value | None — valid response | Response schema check | Unhandled branch, silent default |
| Result values wrong, shape identical | None | Neither; acceptance test | Confident, incorrect answers |
Read the "Caught by" column and the six rows partition without overlap. Two are caught by pre-send validation: a tightened constraint, and an argument removed behind additionalProperties: false. One is caught by the bind-time assertion, which is a tool renamed or removed. Two are caught by the response schema check: a result field removed, and an enum gaining a value. One is caught by neither, which is a result whose shape is identical and whose values are wrong. Two, one, two and one accounts for all six, with no row wanting a guard this chapter has not described and no row claimed by two of them.
The wire-level column is what decides where that partition falls. The first three rows produce -32602, so the server refuses and something on your side receives a response it can act on. The last three produce nothing at all — a missing field, an unfamiliar enum value and a wrong value are each a perfectly valid JSON-RPC response, satisfying the transport, the SDK and your type annotations equally well. An outbound guard can only act on a call it is about to make, and an inbound guard can only act on a document it has received, so each guard's placement follows from which side of the wire the drift becomes visible on. The row visible on neither side is the one that needs an instrument of a different kind.
The last row is the blind spot chapter 4 named — a server that keeps every schema and changes its answers — showing up at a different altitude, and it is the reason a periodic call with known inputs and an expected answer still belongs in the estate.
Drift you have never seen is drift you have never handled
Every guard above has an error path, and error paths that have never executed are approximately as reliable as code that has never been run. The way to get evidence is to make a server drift on purpose, which is cheap because you control both ends in a test: stand up an in-process server with the schemas your snapshot recorded, then stand up a second one with the drift applied and point the same client at it.
it("refuses to send an argument the server no longer accepts", async () => {
const server = mockServer({
tools: [{ ...catalogSearch, inputSchema: tightened({ limit: { maximum: 100 } }) }],
});
const client = await connect(server);
await bindTools(client);
await expect(callTool(client, "catalog_search", { query: "jacket", limit: 500 }))
.rejects.toThrow(/limit must be <= 100/);
expect(server.received).toHaveLength(0); // nothing left the process
});The assertion on server.received is the one that matters, since a test that only checks the thrown error passes just as happily if the request went out and the local validator ran afterwards. Three more cases cover the classes in the table: a server that has renamed a tool, asserting that readiness fails rather than the first call; a result with unit_price removed, asserting a ContractError rather than a rendered NaN; and an enum with an unfamiliar value, asserting that the unhandled branch is loud. Each of these takes ten or fifteen lines against a mock and they are the only evidence that the guards behave as designed under exactly the conditions they exist for.
Where validation is the wrong instrument
Two neighbouring ideas get folded into this and should not be. The first is security: validating arguments against the server's own schema is a correctness control that offers no protection against a hostile server, since the schema comes from the same party as the threat. A malicious server can advertise a schema that accepts anything and ask for credentials in a tool description, and it will pass every check in this chapter. Argument validation protects you from a server that changed, not from a server that lies.
The reason that belief forms is that the guard has the shape of a security control: it inspects input that came from somewhere else and refuses some of it. What it lacks is an independent reference. A validation control is only ever as strong as the source of the rule it enforces, and here the rule arrived in the same tools/list response as the party being validated, from that party. A check whose expected value is supplied by the thing being checked can detect that the thing changed its mind, and cannot detect that it is lying — which is a distinction worth carrying past this chapter, because it separates every correctness control in this topic from every security one.
The second is the contract snapshot from chapter 4, which lives on a different clock. Per-call validation only sees tools you actually call, only when you call them, and only in whatever environment is running — a tool you invoke once a quarter drifts silently for three months. The snapshot job sees the entire surface on a schedule, before use. Running both is not belt and braces; they answer "has anything moved" and "is this specific call still legal" and neither question implies the other.
The runtime cost is small enough not to be part of the decision. Compiling fourteen schemas with Ajv at connect time takes on the order of two to five milliseconds; a compiled validator over a small argument object runs in tens of microseconds, against a tool call whose round trip is 140 milliseconds on the hosted endpoint measured in chapter 2. Where the cost does show up is in schema features: servers occasionally publish schemas using $ref, a draft the validator does not recognise, or a format keyword with no registered implementation, and a validator configured in strict mode will refuse to compile. Failing the bind on an uncompilable schema is a reasonable default for a small estate, and for a large one it is worth degrading to a warning plus a recorded reason, because a tool you cannot validate is still a tool you can call, and knowing which ones those are is itself useful.
Putting the validator's cost and the call's round trip on one scale is what makes the decision trivial rather than a judgement call. A validator running in tens of microseconds, against a round trip of a hundred and forty milliseconds, is smaller by a factor in the thousands, so the guard is invisible beside the call it guards even at the top of its range. The compile cost is paid once per connection rather than once per call, so it divides across every call that connection makes, and a session issuing more than a handful of tool calls has already amortised it below the variation in a single round trip. Neither number is close enough to the other for a measurement to change the answer, which is the useful shape for a performance argument to have: not that the cost is small, but that it is small by enough that it cannot become the deciding term.
The tempting wrong version of all this is silent coercion: clamp limit to 100, drop unknown keys, default a missing unit_price to zero, and keep the run alive. Each of those converts a visible failure into a changed meaning. Clamping turns "search the whole catalogue" into "search the first hundred", and the model, seeing a successful result, will summarise it as complete. Defaulting a price to zero is the same move with a customer-visible consequence. The guard's value is precisely that it stops rather than improvises, and where continuing is genuinely right — a truncated result set is acceptable for a browsing suggestion — the truncation should be stated in the result the model sees rather than performed behind it.
What makes coercion tempting is that it wears the costume of a principle most engineers were taught early: be liberal in what you accept. In a protocol implementation that principle is frequently right, because the inputs it forgives are malformed versions of a message whose meaning is not in doubt. It goes wrong here because none of these inputs is malformed. Each one is the server stating that the meaning of the operation has changed, and clamping a limit does not repair a request — it answers a different question and returns the answer under the original question's name, to a model with no way of knowing which question was answered. That gives a general test for whether a repair is legitimate. If the repaired call means the same thing, repair it. If it means something narrower, the narrowing has to be visible in the result, to whoever is going to act on it.
The schema the server hands over is therefore worth checking at three moments rather than none: the tool names you depend on exist at bind time or the process refuses to become healthy, the arguments satisfy the advertised schema before each send or the call never leaves, and each result matches the shape your code reads or a ContractError names the field that moved. What that buys is not fewer failures — the server changed either way — but failures somebody can be assigned.
All of this assumes the server answers. A guard that refuses a call is working with a live contract in hand, and the harder case is the server that does not respond at all, where there is no schema to check and no error to attribute, and where the model is holding a half-finished task and a strong tendency to fill the gap itself. That is the next chapter.
Consuming servers — read the current version at https://daviddominguez.dev/learn/en/consuming-servers/validating-tool-arguments-and-results