The agent asked for customerId 42, but the tool expected a string. The model received a generic error, tried the same payload again, and spent another turn without new evidence. This does not call for a cleverer prompt. It calls for an executable contract at the boundary between the agent and the code.
Treat AI agent tool calls as untrusted input. In TypeScript, the short path is to declare one schema, validate input before execution, validate output before returning it, and turn predictable failures into data the loop can understand. The model can suggest a call. The runtime decides whether that call exists.

Practical summary
- TypeScript types help during compilation, but they do not validate JSON received at runtime.
- safeParse can return error fields without throwing an exception that ends the loop.
- Tool output needs a schema too, because transport status does not prove a business rule.
- Retry is safe only when the failure is transient and the operation can be repeated without duplicating an effect.
Where does tool-call validation usually fail?
The failure starts when code trusts a type before checking the value. The Model Context Protocol tools specification defines inputSchema for expected parameters and outputSchema for returned structure, but those fields do not replace validation inside the server. Runtime code still receives data produced by a model, client, or integration.
There are four separate boundaries. A provider may reject invalid JSON before the application sees it. A client may serialize extra fields. The server may accept the input and receive a result that violates a business rule. Finally, the agent may read an error string as a valid answer. Each boundary needs an explicit decision.
The classic example is an object built from unknown data:
type GetOrder = {
customerId: string;
includeHistory?: boolean;
};
function execute(input: GetOrder) {
return orders.find(input.customerId);
}
const received: unknown = JSON.parse(payload);
execute(received as GetOrder);
The type assertion only quiets the compiler. It does not turn 42 into a valid input or prevent a missing customerId. If the tool calls an API, queries a database, or sends a message, the failure appears far from its source.
That is also why HTTP 200 does not end the investigation. An API can return a formally valid response with accepted set to false, an unexpected empty list, or a pending state. The tool must define what success means to the agent.
This article follows the TypeScript MCP server with read-only tools. That post focuses on exposing a narrow surface. Here the question is what happens when a call is incomplete, returns the wrong shape, or fails after a partial change.
Citation capsule: A TypeScript type does not protect a tool call received as JSON. The contract must exist at runtime before execution and cover input, output, and error semantics. MCP provides schemas for these boundaries, but the server still has to validate values and stop an apparently plausible response from moving forward without evidence.
How do you turn a schema into a runtime contract?
The schema should be the source used both to infer the type and to validate real data. Zod basics documents safeParse as a discriminated result containing either valid data or a ZodError, without forcing the flow to depend on try/catch. That shape fits a tool executor well.
Start with the smallest operation the agent needs. Do not accept an arbitrary file path, SQL query, or complete configuration object when the task needs only an identifier and an option. Narrow fields reduce ambiguity and make invalid cases easier to test.
import { z } from "zod";
const GetOrderInput = z.object({
customerId: z.string().min(1),
includeHistory: z.boolean().default(false),
});
const GetOrderOutput = z.object({
found: z.boolean(),
customerId: z.string(),
orders: z.array(
z.object({
id: z.string(),
status: z.enum(["pending", "paid", "cancelled"]),
}),
),
});
type GetOrderInput = z.infer<typeof GetOrderInput>;
type GetOrderOutput = z.infer<typeof GetOrderOutput>;
The type and the schema now have different jobs. GetOrderInput is useful for internal calls that already crossed the boundary. GetOrderInput.safeParse is for values from an API, an MCP client, or a model. Do not use the type as a substitute for the second step.
In an MCP server, the same contract can feed the inputSchema exposed to the client and the validation inside the handler. If the selected SDK has a helper that converts Zod to JSON Schema, test the generated result. Do not assume that optional fields, defaults, unions, and unknown keys were translated the way the client expects.
Implementation insight: The value of one schema is not that it removes every model error. It prevents the compiler, the protocol, and the handler from quietly adopting three different definitions. When those versions drift, retry merely repeats a contradiction.
How should you return an error the agent can repair?
A useful error identifies the failed boundary, points to fields that need correction, and states whether the operation ran. MCP client best practices recommend surfacing uncaught errors as script results so the model can self-correct, while recording partial effects that were already committed. That is different from hiding an exception or returning an HTML page.
Use a stable envelope. The example keeps the message short, preserves issue paths, and separates validation failures from transient failures. Do not return a complete ZodError if it contains secrets or internal details.
type ToolErrorCode =
| "INVALID_INPUT"
| "INVALID_OUTPUT"
| "RETRYABLE"
| "FAILED";
type ToolFailure = {
ok: false;
code: ToolErrorCode;
message: string;
issues?: Array<{ path: string; message: string }>;
retryable: boolean;
};
function invalidInput(error: z.ZodError): ToolFailure {
return {
ok: false,
code: "INVALID_INPUT",
message: "Fix the arguments and try again.",
issues: error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
retryable: false,
};
}
The retryable field should not be a model opinion. The executor knows whether the failure came from validation, a timeout, a rate limit, permission, or a business rule. Text can help the agent, but retry policy belongs in code.
An invalid input may be repaired. An expired token may require credential renewal outside the loop. If an API applied a write and failed before responding, a retry may duplicate the effect. The envelope has to make that distinction visible.
This connects to observability for coding agents in CI. Record the tool name, schema version, error code, duration, attempt, and whether a partial effect occurred. Do not record secrets or the complete payload by default. The goal is to reconstruct the decision without creating a second sensitive database.
Citation capsule: A tool-call error should be structured data, not only text for the model to interpret. The minimum envelope reports a code, invalid fields, retryability, and execution state. The agent can then correct arguments or escalate without confusing a timeout with a business rejection or a partial effect.
How do you validate output before the next step?
Validate the result before returning it to the agent. The MCP tools specification supports outputSchema and recommends structured content when a tool provides that contract. Your application should apply the same rule even when the client does not validate the response.
async function getOrder(rawInput: unknown): Promise<GetOrderOutput | ToolFailure> {
const input = GetOrderInput.safeParse(rawInput);
if (!input.success) return invalidInput(input.error);
try {
const rawOutput = await orders.find(input.data.customerId, {
includeHistory: input.data.includeHistory,
});
const output = GetOrderOutput.safeParse(rawOutput);
if (!output.success) {
return {
ok: false,
code: "INVALID_OUTPUT",
message: "The service returned an unknown tool shape.",
retryable: false,
};
}
return output.data;
} catch (error) {
if (isTemporary(error)) {
return {
ok: false,
code: "RETRYABLE",
message: "The service is temporarily unavailable.",
retryable: true,
};
}
return {
ok: false,
code: "FAILED",
message: "The tool did not complete the operation.",
retryable: false,
};
}
}
The return type can be refined with a discriminated union that includes ok true on success. Make that choice before connecting the executor to the agent. A reviewer should distinguish an empty list from a missing order, and both from a failed call.
Do not use free text as the only output when the next step depends on a decision. Text is still useful for a user-facing explanation, but the system should receive fields it can test. For a list, define size and ordering. For a state, include an identifier and the observed version.
When the flow grows, connect this boundary to regression evals for coding agents. Test invalid input, broken output, a transient error, a valid empty response, and a business rule that rejects a request despite transport success.
When is a retry self-correction, and when is it duplication?
Retry only when the executor can justify repeating the call. Current OpenAI model and tool-calling guidance recommends documenting fields, types, error behavior, concurrency, attempt limits, and stopping conditions. That contract belongs in code and tests, not only in the prompt.
| Failure | Retry? | Next action |
|---|---|---|
| Invalid input | Yes, once after correcting fields. | Return the issues to the agent. |
| Timeout before the effect is known | Not automatically for a write. | Check idempotency or escalate. |
| Temporary read limit | Yes, with a cap and backoff. | Retry without widening scope. |
| Business-rule rejection | Not as the same call. | Request a new decision or report the reason. |
A read-only tool may have a simpler policy, but it can still overload an API. A tool that creates a charge, sends a message, or changes state needs an idempotency key, a state query, or human confirmation. The agent should not infer retry safety from the fact that a call failed.
Long loops also make error history expensive to carry. When a run crosses many Claude Code or Codex turns, I use RemoteCode to reduce repeated context in agentic workflows. It is my own tool, mentioned here because short handoffs and structured evidence help both token budgets and failure recovery.
Citation capsule: Retry is a semantic decision, not an automatic reaction to an exception. Idempotent reads can repeat a transient failure within a cap. Writes that may already have been applied need a state check or an idempotency key before another attempt.
How do you verify that the contract works?
The contract works when tests prove three things: invalid input never reaches the service, invalid output never reaches the next step, and a temporary failure cannot create an unbounded loop. Run this check with a fake executor before connecting the tool to a real model.
The prerequisites are Node.js, TypeScript with strict mode, Zod, and the test runner already used by the project. Zod documentation recommends strict mode in tsconfig.json and documents safeParse as a typed success-or-error result. Do not pin versions without the repository dependency tree.
it("does not call the service with invalid input", async () => {
const find = vi.fn();
const result = await executeTool({ customerId: 42 }, { find });
expect(result).toMatchObject({
ok: false,
code: "INVALID_INPUT",
retryable: false,
});
expect(find).not.toHaveBeenCalled();
});
it("rejects output that breaks the contract", async () => {
const find = vi.fn().mockResolvedValue({
found: true,
customerId: "cus_1",
orders: [{ id: "ord_1", status: "unknown" }],
});
const result = await executeTool(
{ customerId: "cus_1" },
{ find },
);
expect(result).toMatchObject({
ok: false,
code: "INVALID_OUTPUT",
});
});
Add cases for an extra field, a missing required field, null, an unknown enum, and a timeout. Then run an integration test through the MCP transport used in production. A unit test that calls the handler directly will not reveal serialization, negotiation, or outputSchema incompatibility.
If the agent receives INVALID_INPUT, it should correct only the reported fields. If it receives INVALID_OUTPUT, the flow should stop and create evidence for an operator. If it receives RETRYABLE, the executor applies local policy. That separation is the small harness that prevents the model from inventing the next step.
What the schema cannot solve
A schema does not authenticate a call, authorize a tool, or prove that a response meets product policy. It controls shape and types. Permissions, scope, secrets, rate limits, audit, and approval remain runtime and service responsibilities.
Do not treat tool descriptions and annotations as absolute authority either. The MCP specification tells clients to consider annotations untrusted when they do not come from trusted servers. The MCP allowlist design for CI explains why schema contracts and access control belong in separate layers.
Migration is another limit. When a schema changes, record its version in the trace and test older clients. A new default can hide that the model still sends an old shape. An optional field can pass parsing and fail a business rule. Compatibility has to be a documented decision.
The useful boundary is simple: the agent proposes, the schema can reject, the tool can execute, and verification can stop the flow. When those functions are mixed inside a prompt, the system loses the ability to say which part failed.
Frequently asked questions
Does TypeScript validate tool-call arguments by itself?
No. TypeScript checks relationships between values during compilation, but a runtime payload is still untrusted unknown data. Put a Zod schema at the boundary and derive the type from it. Zod documents safeParse as the way to separate valid data from a ZodError without confusing the two paths.
Should I accept extra fields sent by the agent?
Only when they belong to a separate envelope with its own policy. Extra fields mixed into tool input can hide a contract error or metadata the handler should not consume. If the client adds audit information, extract that envelope first, then validate functional arguments with a narrow schema.
Should every tool error return isError?
The protocol and SDK define their own failure signals, so follow the contract of the MCP client you use. Also return an application envelope that distinguishes invalid input, invalid output, transient failure, and permanent failure. One boolean does not tell you whether retry is safe or whether a partial effect occurred.
Should an agent repair its own error?
It can repair small input errors when the executor returns clear fields and limits. Do not let it retry indefinitely or reopen the entire task during repair. For invalid output, partial effects, denied permission, or business rejection, the safer path is usually to stop, record evidence, and request a new decision.
Do I need output validation if the API already returns JSON?
Yes. JSON guarantees transport syntax only. An output schema checks fields, types, enums, and invariants expected by the next step. A JSON response with an unknown state can be syntactically correct and operationally dangerous. Validate it before returning it to the agent and test every recognized state.
Sources consulted
- Model Context Protocol tools specification, retrieved 2026-07-27.
- MCP client best practices, retrieved 2026-07-27.
- Zod basics, retrieved 2026-07-27.
- OpenAI Developers model and tool-calling guidance, retrieved 2026-07-27.
- Zod documentation, retrieved 2026-07-27.