A model that answers wrongly produces a bad paragraph. A model that calls a tool wrongly deletes a record, emails a customer, or runs a query that scans a warehouse. Tool calling — whether through a provider's function-calling API or through the Model Context Protocol — moves the risk from what the model says to what your code does on its behalf.
This guide covers the failures that show up once tool calling meets production traffic, the signal each leaves, and a small wrapper that addresses them.
How the pieces fit
The Model Context Protocol uses JSON-RPC 2.0 messages between hosts (the LLM application), clients (connectors inside the host) and servers that offer resources, prompts and tools1. A client discovers tools with tools/list and invokes one with tools/call2. The model never talks to an MCP server. It sees tool definitions in its prompt, returns a tool call, and your host decides whether to execute it.
Your host (MCP client)
INFRO gateway
Model provider
MCP server
- Your host (MCP client) to MCP server: tools/list (cache it; order is deterministic)
- Your host (MCP client) to INFRO gateway: chat completion with tools[] converted from inputSchema
- INFRO gateway to Model provider: Forwarded, or translated for Anthropic
- INFRO gateway to Your host (MCP client): finish_reason tool_calls
- Your host (MCP client) to Your host (MCP client): Allowlist, validate, confirm, start timeout
- Your host (MCP client) to MCP server: tools/call
- MCP server to Your host (MCP client): result or isError: true
- Your host (MCP client) to INFRO gateway: tool message, next model call
That places every control in the host. The MCP specification is explicit that it "cannot enforce these security principles at the protocol level" and that implementors should build consent, authorization and access controls into their applications1.
What the specification asks of you
The tools section of the current specification splits responsibilities. Servers must validate all tool inputs, implement access controls, rate limit tool invocations and sanitize tool outputs. Clients should prompt for confirmation on sensitive operations, show tool inputs to the user before calling the server, validate tool results before passing them to the model, implement timeouts for tool calls, and log tool usage for audit2. It also says there should always be a human in the loop with the ability to deny tool invocations, and that clients must consider tool annotations untrusted unless they come from trusted servers2.
Two further rules shape production design. Tool names should be unique within a server, but a client that aggregates several servers may hit collisions and should disambiguate, for example by prefixing names with a server identifier — and should not rely on the server's own name for that, because it is not guaranteed unique2. And tool failures come in two kinds: protocol errors (an unknown tool, a malformed request) as JSON-RPC errors, and tool execution errors reported in the result with isError: true, which clients should pass back to the model so it can correct itself2.
Seven failures and their signals
| Signal | Fix | |
|---|---|---|
| Hung tool call | Step never returns; run exceeds its deadline | Per-call timeout; return isError text to the model |
| Invented arguments | Schema validation fails, or a forced tool gets guessed values | Validate before executing; strict schemas where supported |
| Tool-name collision | Two servers expose search | Prefix names with a server id you assign |
| Tool definitions bloat | Input tokens high on every step | Expose fewer tools per turn; load on demand |
| Oversized result | One step's input jumps by tens of thousands of tokens | Cap and summarize results before returning them |
| Instruction in a result | Model acts on text a tool returned | Treat results as data; confirm sensitive actions |
| Retry loop | Same tool and arguments repeat | Repeat detection in the run guard |
1. The hung call. A tool that waits on a slow API holds the whole run. Without a timeout, the agent's step never returns and the user's request times out somewhere else, with no record of which tool stalled.
2. Invented arguments. Arguments are model-generated JSON. A model forced into a tool it would not have chosen will fill required fields with plausible guesses. Anthropic's documentation shows a model inferring a location the user never gave when a required parameter was missing3. Strict schema modes help with shape — OpenAI's strict mode requires additionalProperties: false and every field listed as required4 — but a schema-valid argument can still be wrong.
3. Name collisions. Two servers each expose search. Whichever registered last wins, and the model's call reaches a server it did not intend.
4. Tool-definition bloat. Every tool definition is sent on every model call. OpenAI notes that function definitions count against the context limit and are billed as input tokens, and suggests keeping fewer than 20 functions available per turn4. Anthropic adds a tool-use system prompt of a few hundred tokens on top of your schemas3. Connecting five MCP servers with forty tools each puts all two hundred definitions into every step.
5. Oversized results. A tool that returns a full web page or a 10,000-row query result dumps it into the history, where it is re-sent on every later step. One bad result can dominate the cost of a run.
6. Instructions inside results. A tool result is data from the outside world: a web page, an email, a ticket. If it contains text that reads like instructions, the model may follow it. This is prompt injection through the tool channel, and it is why the specification asks clients to validate results and keep a person able to deny calls.
7. Retry loops. The model calls a tool, gets an error it does not understand, and calls it again with the same arguments. Nothing stops this except your loop's repeat detection; see bounding agent loops.
A tool-call wrapper in TypeScript
The wrapper below sits between the model's tool call and whatever executes it — an MCP client's callTool, or a local function. It enforces an allowlist of prefixed names, validates arguments with a JSON Schema validator, applies a per-call timeout, caps the result, requires confirmation for tools you mark as sensitive, and always returns something the model can read.
import Ajv, { type ValidateFunction } from "ajv";
type ToolResult = { content: string; isError: boolean };
export interface GuardedTool {
server: string; // your own id for the MCP server, not serverInfo.name
name: string; // the server's tool name
description: string; // untrusted if the server is not yours: review it
inputSchema: object;
sensitive?: boolean; // writes, sends, deletes, payments
timeoutMs?: number;
execute: (args: unknown, signal: AbortSignal) => Promise<unknown>;
}
const ajv = new Ajv({ allErrors: true, strict: false });
const MAX_RESULT_CHARS = 16_000;
export class ToolGate {
private tools = new Map<string, { tool: GuardedTool; validate: ValidateFunction }>();
constructor(
private allowed: Set<string>, // e.g. "crm.lookup_customer"
private confirm: (name: string, args: unknown) => Promise<boolean>,
private audit: (entry: Record<string, unknown>) => void,
) {}
register(tool: GuardedTool) {
const qualified = `${tool.server}.${tool.name}`;
if (!this.allowed.has(qualified)) return; // not on the allowlist: never exposed
// Provider-safe name: letters, digits, _ and -, at most 64 characters.
const safe = `${tool.server}__${tool.name}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
if (this.tools.has(safe)) throw new Error(`tool name collision on ${safe}`);
this.tools.set(safe, { tool, validate: ajv.compile(tool.inputSchema) });
}
/** Definitions for the model, in a stable order so prompt caches keep hitting. */
definitions() {
return [...this.tools.keys()].sort().map((safe) => ({
type: "function" as const,
function: {
name: safe,
description: this.tools.get(safe)!.tool.description,
parameters: this.tools.get(safe)!.tool.inputSchema,
},
}));
}
async call(runId: string, callId: string, modelName: string, rawArgs: string): Promise<ToolResult> {
const entry = this.tools.get(modelName);
const qualified = entry ? `${entry.tool.server}.${entry.tool.name}` : modelName;
const started = Date.now();
const done = (result: ToolResult, outcome: string) => {
this.audit({ runId, callId, tool: qualified, outcome, ms: Date.now() - started });
return result;
};
if (!entry) return done({ content: `Tool ${modelName} is not available.`, isError: true }, "unknown");
let args: unknown;
try {
args = JSON.parse(rawArgs);
} catch {
return done({ content: "Arguments were not valid JSON. Send a JSON object.", isError: true }, "bad_json");
}
if (!entry.validate(args)) {
const why = ajv.errorsText(entry.validate.errors, { separator: "; " });
return done({ content: `Invalid arguments: ${why}`, isError: true }, "invalid_args");
}
if (entry.tool.sensitive && !(await this.confirm(qualified, args))) {
return done({ content: "The user declined this action.", isError: true }, "declined");
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), entry.tool.timeoutMs ?? 15_000);
try {
const output = await entry.tool.execute(args, controller.signal);
let text = typeof output === "string" ? output : JSON.stringify(output);
if (text.length > MAX_RESULT_CHARS) {
text = `${text.slice(0, MAX_RESULT_CHARS)}\n[truncated: ${text.length} characters total]`;
}
return done({ content: text, isError: false }, "ok");
} catch (err) {
const reason = controller.signal.aborted ? "timed out" : (err as Error).message.slice(0, 300);
return done({ content: `${qualified} failed: ${reason}`, isError: true }, controller.signal.aborted ? "timeout" : "error");
} finally {
clearTimeout(timer);
}
}
}The choices behind it:
- The allowlist is applied at registration, so a tool a server adds later is never shown to the model until someone adds it to the list. The spec lets servers change their tool list over time and notify clients2; a change should be a review, not an automatic expansion of what the model can do.
- Names are prefixed with your id for the server, which resolves collisions without trusting server-supplied names. The provider-facing name is reduced to letters, digits,
_and-within 64 characters, and a collision after that reduction fails loudly at registration instead of routing a call to the wrong server. - Every failure becomes a readable tool error. Returning
isError: truewith a short explanation lets the model try something else; throwing ends the run with no explanation to anyone. - Truncation says it truncated. A silent cut invites the model to reason from half a document as if it were whole.
- The audit entry carries the run id, so a tool call can be found next to the model calls around it.
For MCP servers, execute wraps the client's tools/call and passes the abort signal into its cancellation; the specification lists cancellation among its utilities1. If a server declares an outputSchema, validate structuredContent against it too — the spec says clients should2.
Host
Before the call
- Allowlist of server-prefixed tool names
- JSON Schema validation of arguments
- Human confirmation for writes and sends
- Scoped credential for that server only
Host
During the call
- Timeout per call and per run
- Concurrency limit per server
- Cancellation when the run is stopped
Host and gateway
After the call
- Result size cap and outputSchema check
- isError results returned to the model
- Audit log entry with the run's trace id
- Model spend held by gateway ceilings
Credentials and the servers themselves
Most MCP security incidents are not about the model. The specification's security best practices cover them in detail5:
- No token passthrough. An MCP server must not accept tokens that were not issued for it, and must not forward a client's token to a downstream API.
- Scope minimisation. Start with a minimal scope set and elevate for privileged operations, rather than requesting every scope up front; broad tokens expand the blast radius when one leaks.
- Local servers run code. A one-click local server configuration must show the exact command and require explicit approval, and local servers should run sandboxed with restricted file system and network access.
- State handles are not authentication. A cart id or workflow id passed between tool calls must be bound to the authenticated user and checked on every call.
- SSRF. A client that follows URLs from a server's metadata should require HTTPS, block private and link-local address ranges, and validate redirects.
For a server you operate, the tools section adds rate limiting of tool invocations2 — the tool-side counterpart of OWASP's advice to rate limit and throttle resource-intensive operations6.
Tool calling through INFRO
INFRO is a model gateway, not an MCP host. It does not speak MCP, connect to MCP servers or execute tools. What it does is carry the model side of the loop across providers, on your own provider accounts (tool calling):
- One tool schema for every provider. Requests use the OpenAI
toolsandtool_choiceshape. Most provider kinds receive it as is; for Anthropic connections INFRO translates tools,tool_choice, tool calls and tool results to and from Anthropic'stool_useblocks. Convert each MCP tool'sinputSchemainto a function definition once, in the host, and the same definitions work whichever connection serves the request. - Capability is not assumed. INFRO does not record tool support per model yet; if a provider rejects
tools, the400carries the provider's own message. - Tool definitions are budgeted. A blocking budget's worst-case estimate covers the whole request body a provider bills, including tool definitions and schemas (spend controls), so large tool sets hold more of a budget per request.
- Tool rounds are visible.
finish_reasonis recorded per request and filterable astool_calls, and tagging each model call withmetadata.step—tool_select,answer— separates tool rounds from final answers inGET /v1/traces/{trace_id}(request traces). - Model access is governed. Model allowlists on the organization, project or key limit which models an agent can use at all (model allowlists).
Keep tool definitions byte-stable between requests — same order, same text — and provider-side prompt caching can keep serving them; the definitions() method above sorts for exactly that reason, and the MCP specification asks servers to return tools in a deterministic order for the same purpose2.
Checklist
- Every exposed tool is on an explicit allowlist of server-prefixed names; new server tools require review.
- Arguments are parsed and validated against the input schema before any execution.
- Sensitive tools (writes, sends, deletes, payments) require a person's confirmation.
- Every tool call has a timeout and a result-size cap, and truncation is stated in the result.
- Failures return
isError: truewith a short reason; the run's repeat detection stops identical retries. - Tool results are treated as data: never as instructions, never rendered unescaped.
- Each MCP server has its own narrowly scoped credential; no token is passed through.
- Local servers run sandboxed; remote server URLs are validated against SSRF.
- Each step exposes only the tools it needs, in a stable order.
- Every tool call is logged with the run's trace id beside the model calls.
