Skip to content

Agents and multimodal systemsGuide

MCP and tool calling in production: failure modes and fixes

Tool calling moves the risk from what a model says to what your code does. This guide lists the ways MCP and function-calling integrations fail in production, the signal each leaves, and a wrapper that fixes them.

By INFRO Editorial

Published

10 min read

Glowing amber request paths radiating from a central hub node to surrounding nodes on a dark ground, representing MCP tool calls

In short

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 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

  1. Your host (MCP client) to MCP server: tools/list (cache it; order is deterministic)
  2. Your host (MCP client) to INFRO gateway: chat completion with tools[] converted from inputSchema
  3. INFRO gateway to Model provider: Forwarded, or translated for Anthropic
  4. INFRO gateway to Your host (MCP client): finish_reason tool_calls
  5. Your host (MCP client) to Your host (MCP client): Allowlist, validate, confirm, start timeout
  6. Your host (MCP client) to MCP server: tools/call
  7. MCP server to Your host (MCP client): result or isError: true
  8. Your host (MCP client) to INFRO gateway: tool message, next model call
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 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

SignalFix
Hung tool callStep never returns; run exceeds its deadlinePer-call timeout; return isError text to the model
Invented argumentsSchema validation fails, or a forced tool gets guessed valuesValidate before executing; strict schemas where supported
Tool-name collisionTwo servers expose searchPrefix names with a server id you assign
Tool definitions bloatInput tokens high on every stepExpose fewer tools per turn; load on demand
Oversized resultOne step's input jumps by tens of thousands of tokensCap and summarize results before returning them
Instruction in a resultModel acts on text a tool returnedTreat results as data; confirm sensitive actions
Retry loopSame tool and arguments repeatRepeat detection in the run guard
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 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.

guarded-tools.ts
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: 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 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
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 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 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), 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).
  • 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: 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 readers ask

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.

From the team that builds it

Where this shows up in INFRO

INFRO is a control plane in front of the provider accounts you already hold. The capabilities below are the parts of it this article touches, each with the documentation that defines its behaviour.

  • Text — OpenAI-compatible

    Private early access

    POST /v1/chat/completions with the request shape the OpenAI SDKs already send, including streaming and tool calling.

    Read the documentation for Text — OpenAI-compatible
  • Request traces and payload logs

    Private early access

    Per-request timing detail, with prompt and completion logging off by default and a retention window you choose.

    Read the documentation for Request traces and payload logs
  • Budgets and spend ceilings

    Private early access

    A hard cap per organization, project, member, and key, with alerts at your own thresholds.

    Read the documentation for Budgets and spend ceilings
  • Model allowlists

    Private early access

    Approve which models a project may call; anything else returns a policy error instead of a line item.

    Read the documentation for Model allowlists
  • MCP gateway for your agents' tools

    Private early access

    Register your own MCP servers once with their credentials, approve and pin each tool's definition, grant gateway keys access, and let agents call those tools through INFRO with guardrails, a call log on the same trace as their model calls, and kill switches checked on every call. INFRO executes no tool.

    Read the documentation for MCP gateway for your agents' tools
  • Guardrails

    Private early access

    Pattern-based checks on chat completion prompts and responses: block, redact or flag email addresses, phone numbers, card numbers, IBANs, US Social Security numbers, IP addresses, API keys and your own terms, plus optional classifier checks on your own connection to a decision model's provider. Detection can miss things. Included from the Business plan.

    Read the documentation for Guardrails

INFRO is in private early access. Provider usage is billed to you by your providers; INFRO charges a software subscription and takes 0% of model spend. See plans.

Sources

Numbered in order of first citation. Each was opened and checked against the claim on the date shown.

  1. 1.Model Context Protocol specification (2026-07-28) (opens in a new tab) — Model Context Protocol. Accessed Sep 29, 2026.
  2. 2.Tools — Model Context Protocol specification (opens in a new tab) — Model Context Protocol. Accessed Sep 29, 2026.
  3. 3.Tool use with Claude (opens in a new tab) — Anthropic. Accessed Sep 29, 2026.
  4. 4.Function calling (opens in a new tab) — OpenAI. Accessed Sep 29, 2026.
  5. 5.Security Best Practices — Model Context Protocol (opens in a new tab) — Model Context Protocol. Accessed Sep 29, 2026.
  6. 6.LLM10:2025 Unbounded Consumption (opens in a new tab) — OWASP GenAI Security Project. Accessed Sep 29, 2026.

About this article

Written from the Model Context Protocol specification (version 2026-07-28), OpenAI and Anthropic tool-use documentation, and the public documentation of INFRO, which the authors build. The wrapper code is written for this article and is not an MCP SDK.

Last checked against its sources on . The next review is due by . No separate named reviewer is recorded for this article.

How articles are written, sourced and corrected is set out in the editorial standards.