# MCP and tool calling in production: failure modes and fixes

URL: https://infro.io/blog/mcp-tool-calling-production
Author: INFRO Editorial (https://infro.io/blog/editorial)
Publisher: INFRO
Published: 2026-09-29
Last modified: 2026-09-29
Last reviewed: 2026-09-29 (next review due 2026-10-29)
Topic: Agents and multimodal systems
Format: Guide

> Treat every tool call as untrusted input to your own code: allow only named, server-prefixed tools, validate arguments against the schema before executing, put a timeout and a size cap on every call, return failures to the model as tool errors, confirm sensitive actions with a person, and log each call against the run's trace id. The model provider and the gateway see the tool definitions and results as tokens; the host that runs the MCP client is where the controls must live.

Key takeaways:
- Tool annotations and descriptions from a server you do not control are untrusted input; the MCP specification says so explicitly.
- Validate model-generated arguments before executing anything; a forced tool call guarantees a call, not correct arguments.
- Every tool call needs a timeout, a result-size cap and an error path that returns a tool error the model can read.
- Prefix tool names with a server id you assign; two servers exposing the same name is a routing bug waiting to happen.
- Tool definitions and results are input tokens on every step. Expose only the tools a step needs.

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 tools[1]. A client discovers tools with `tools/list` and invokes one with `tools/call`[2]. 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.

[Diagram: The host sits in the middle of every call. The model provider (and any gateway in front of it) sees tool definitions and results only as tokens; the MCP server sees only calls your host chose to make.]

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 applications[1].

## 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 audit[2]. 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 servers[2].

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 unique[2]. 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 itself[2].

## Seven failures and their signals

[Diagram: Each failure leaves a signal you can alert on before a user reports it.]

**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 missing[3]. Strict schema modes help with shape — OpenAI's strict mode requires `additionalProperties: false` and every field listed as required[4] — 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 turn[4]. Anthropic adds a tool-use system prompt of a few hundred tokens on top of your schemas[3]. 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](https://infro.io/blog/bound-ai-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.

```typescript
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 clients[2]; 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: true` with 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 utilities[1]. If a server declares an `outputSchema`, validate `structuredContent` against it too — the spec says clients should[2].

[Diagram: Controls before, during and after each call. Only the last item lives outside the host.]

## Credentials and the servers themselves

Most MCP security incidents are not about the model. The specification's security best practices cover them in detail[5]:

- **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 invocations[2] — the tool-side counterpart of OWASP's advice to rate limit and throttle resource-intensive operations[6].

## 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](https://infro.io/docs/features/tool-calling)):

- **One tool schema for every provider.** Requests use the OpenAI `tools` and `tool_choice` shape. Most provider kinds receive it as is; for Anthropic connections INFRO translates tools, `tool_choice`, tool calls and tool results to and from Anthropic's `tool_use` blocks. Convert each MCP tool's `inputSchema` into 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`, the `400` carries 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](https://infro.io/docs/org/spend-controls)), so large tool sets hold more of a budget per request.
- **Tool rounds are visible.** `finish_reason` is recorded per request and filterable as `tool_calls`, and tagging each model call with `metadata.step` — `tool_select`, `answer` — separates tool rounds from final answers in `GET /v1/traces/{trace_id}` ([request traces](https://infro.io/docs/observability/traces)).
- **Model access is governed.** Model allowlists on the organization, project or key limit which models an agent can use at all ([model allowlists](https://infro.io/docs/org/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 purpose[2].

## 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: true` with 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.

## Questions

### Is an MCP server safe to connect if it comes from a well-known vendor?

It is safer, not safe by default. The server's tools still execute with whatever credentials you give it, its results still enter the model's context, and its tool list can change. Scope its credential to what the workflow needs, pin the tool names you allow, and review the list when the server announces a change.

## Sources

1. Model Context Protocol specification (2026-07-28) — Model Context Protocol. https://modelcontextprotocol.io/specification/latest (accessed 2026-09-29)
2. Tools — Model Context Protocol specification — Model Context Protocol. https://modelcontextprotocol.io/specification/2026-07-28/server/tools (accessed 2026-09-29)
3. Tool use with Claude — Anthropic. https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview (accessed 2026-09-29)
4. Function calling — OpenAI. https://developers.openai.com/api/docs/guides/function-calling (accessed 2026-09-29)
5. Security Best Practices — Model Context Protocol — Model Context Protocol. https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices (accessed 2026-09-29)
6. LLM10:2025 Unbounded Consumption — OWASP GenAI Security Project. https://genai.owasp.org/llmrisk/llm102025-unbounded-consumption/ (accessed 2026-09-29)
