A Tool Call Is a Backend Call
The model choosing a tool is a client choosing an endpoint — and the server owes exactly what it always owed.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
If the model selects the tool and fills in the arguments, what is still the backend's responsibility?
The assistant needs to look up orders, update addresses and issue refunds. Someone has written three tool functions and wired them to the model.
The tools are internal functions called by our own agent runtime, so they can take the arguments they are given and do the work. The prompt tells the model to only refund orders belonging to the current user.
The prompt is a request, not a control. A model can be persuaded — by a user message, a support ticket it read, or a product description in the database — to call the refund tool with a different order id (Indirect Prompt Injection in the Agentic AI domain).
- The prompt is a request, not a control. A model can be persuaded — by a user message, a support ticket it read, or a product description in the database — to call the refund tool with a different order id (Indirect Prompt Injection in the Agentic AI domain).
refund(orderId)with no ownership check is a broken-object-level-authorization vulnerability whether the caller is a browser or a model (Object-Level Authorization).- Arguments arrive as free-form values from a text generator: an id that is not a UUID, an amount that is negative, a date in the wrong century, a string where an enum was expected.
- The model calls the same tool twice because the first result did not look like what it expected, and the customer is refunded twice (Idempotency in Backends).
- A tool that takes a filter or a fragment of query text becomes an injection vector into whatever it interpolates (SQL Injection).
- A tool with no rate limit gets called in a loop, and one user's conversation generates thousands of calls against an internal service (Rate Limiting).
What is actually happening
- The tool schema is an API contract and the model is a client. Everything the domain says about untrusted clients applies unchanged: the contract describes what a well-behaved caller sends, and the server assumes nothing (The Trust Boundary).
- This client is unusual in one respect that makes it *less* trustworthy, not more: its behaviour is influenced by text it reads at runtime, some of which an attacker may have written.
- Tool selection is therefore routing, not authorization. Choosing an endpoint has never granted permission to call it, and nothing about the model changes that (Authentication vs Authorization).
- Identity does not come from the model. It comes from the authenticated request that started the agent run, and it must be carried to the tool handler out-of-band — never as a model-supplied argument (Request Context Propagation).
- Validation belongs in the handler even though the schema is also given to the model. The schema improves the odds of well-formed calls; it enforces nothing (Transport Validation).
- The tool result flows back into the prompt, so the handler's output is also a trust boundary: it becomes input to the next model turn (Not Leaking Your Internals).
The same handler, written twice
The clearest way to see the point is a tool handler written the way it usually is first, and then the way it would be written if the caller were a browser. The second version is not more sophisticated. It is the ordinary endpoint the team already knows how to write.
// tool: refund_order
// "the prompt says to only refund the current user's orders"
async function refundOrder(args: {
userId: string // <- supplied by the model
orderId: string
amountCents: number
}) {
const order = await db.orders.findById(args.orderId)
await payments.refund(order.chargeId, args.amountCents)
return { ok: true }
}// identity comes from the run context, not the arguments
async function refundOrder(ctx: RunContext, raw: unknown) {
const args = RefundArgs.parse(raw) // reject, do not coerce
const order = await db.orders.findById(args.orderId)
if (!order) return toolError('not_found')
// object-level authorization, every call
if (order.userId !== ctx.userId) {
metrics.inc('tool.authz_denied', { tool: 'refund_order' })
return toolError('not_found') // do not confirm existence
}
if (!policy.refundAllowed(order, ctx.actor)) return toolError('not_allowed')
if (args.amountCents > order.refundableCents) return toolError('amount_too_large')
// repeated selection of the same tool must not refund twice
const key = `refund:${order.id}:${args.amountCents}`
const res = await payments.refund(order.chargeId, args.amountCents, {
idempotencyKey: key,
})
await audit.record(ctx, 'refund_order', args, res)
return { ok: true, refundId: res.id }
}The second version does not trust the caller for identity, existence, permission, amount or repetition — the five things a browser client is never trusted for either. The first version is a broken-object-level-authorization vulnerability that also double-refunds, and no amount of prompt engineering fixes either property.
The dispatch pipeline every tool call should pass through
Written once as middleware, this is the same pipeline an HTTP request goes through — and for the same reasons. Writing it per handler is how one tool ends up missing the ownership check.
- 1Parse the tool request
Turn the model's output into a tool name and an argument object
fails by Malformed or hallucinated tool name; must be rejected, not guessed at
- 2Resolve identity from context
Attach the authenticated user and tenant from the run, never from arguments
fails by Identity accepted as an argument — the single most damaging mistake here
- 3Check the tool is permitted
Is this tool available to this user, tenant and plan at all?
fails by Tool list built once at startup and assumed universal (Role-Based Access Control)
- 4Validate arguments
Schema parse into a domain type; reject on failure
fails by Coercion turning invalid values into plausible ones (Parse, Do Not Validate)
- 5Authorize the object
Load the target and check this user may act on it
fails by Checked in the runtime rather than the handler, so other callers bypass it
- 6Apply limits
Per-user and per-conversation rate limit; cost and step budget
fails by Global limits only, so one conversation can consume the shared allowance (Budgets, Deadlines and Step Limits)
- 7Execute with a timeout
Run the handler under a deadline and an idempotency key
fails by A hanging tool hanging the entire agent run (Timeouts)
- 8Sanitise the result
Return a typed result; strip internals and other tenants' data
fails by Raw errors and internal fields entering the model context (Not Leaking Your Internals)
- 9Record it
Audit entry: who, which tool, which arguments, what changed
fails by Logged only on success, so denied and failed calls are invisible (Agent Audit Logs)
Choosing the tool surface
How much a tool can do is a security decision made at design time, and it is far more effective than anything downstream. The narrower the tool, the smaller the space of harmful calls — and the less the model can accomplish, which is the honest trade.
What should the model be able to express through this tool?
when The action is known: refundOrder(orderId, amountCents, reason).
cost A new tool per capability; the tool list grows and the prompt gets longer.
when Search or filtering over one entity with an allowlist of fields and operators.
cost Less expressive than the model would like; more schema to maintain.
when Almost never on user-facing paths. Internal analytics with a read-only, row-limited, tenant-scoped role at most.
cost The blast radius of the credential it runs under, plus injection surface (SQL Injection).
when Only with a host allowlist, no redirects, and no internal network reachability.
cost SSRF surface directly proportional to how permissive the allowlist is (SSRF — When the Backend Fetches a URL).
when Irreversible, high-value or hard to reconcile: payouts, deletions, external notifications.
cost A human in the path and a slower experience — the only reliable control for irreversibility.
How to build it
Most important first.
- Derive identity from the session, not the arguments. The handler signature should make
userIdimpossible for the model to supply. - Authorize every object, every call.
refund(orderId)must load the order and check ownership, exactly as the HTTP endpoint would (Object-Level Authorization). - Validate arguments against a schema in the handler, and reject rather than coerce. Parse into a domain type so invalid values cannot reach the logic (Parse, Do Not Validate).
- Make write tools idempotent with a key derived from the intent, so a repeated tool call is a no-op rather than a second refund (Idempotency Keys).
- Rate-limit and budget per user and per conversation, not just globally (Rate Limiting).
- Give each tool a timeout and treat it as a dependency, because a hanging tool hangs the whole agent run (Timeouts).
- Design tool surfaces narrowly.
refundOrder(orderId, reason)is safer thanexecuteSql(query)for exactly the reasons a narrow endpoint is safer than a generic one (What a Handler Is Responsible For). - Require human approval for irreversible or high-value actions rather than trying to express the policy in the prompt (Approval Gates and Risk Classes in the Agentic AI domain).
- Sanitise what the tool returns. Internal errors, stack traces and other users' data must not enter the context (Error Boundaries: Three Translations, Not One).
What can go wrong
- Authorization implemented once in the agent runtime rather than in each handler, so a tool called from anywhere else — a test, a job, a second agent — bypasses it.
- A schema-validation library used to *coerce* rather than reject, silently turning
"-50"into-50. - Idempotency keys generated by the model, which means they are not stable across attempts and provide no protection.
- A "read-only" tool that is not: a search tool that writes an audit row, or a lookup that triggers a cache refill with side effects.
- Tool errors returned as raw exception text, leaking internal structure into the model context and often into the user-visible answer.
- Per-tool rate limits with no per-conversation limit, so a loop stays under every individual limit and still generates enormous load.
- A model may issue parallel tool calls that write the same row concurrently, with no coordination between them (Optimistic Concurrency).
- A retried tool call can race the original if the first attempt timed out but is still running (Duplicate Detection).
- Two conversations for the same user can act on the same entity at once, exactly like two browser tabs (Backend Races).
- Missing object-level authorization in a tool is the same vulnerability class as missing it in an endpoint, and it is the most common serious flaw in agent backends (Object-Level Authorization).
- Prompt instructions are not a security control. "Only refund orders belonging to the current user" is a hope; a query filtered by session user id is a control (The Model Is Not the Authorization Layer in the Security domain).
- Any tool that accepts a URL, host or path is SSRF and path-traversal surface with a new caller (SSRF — When the Backend Fetches a URL).
- Tool results become model input, so a tool that returns attacker-controlled content is an injection channel — and one that returns a secret has exfiltrated it into the provider's context (Tool Misuse and Data Exfiltration in the Agentic AI domain).
- Tool schemas and descriptions are read by the model at runtime; treat a third-party tool server's descriptions as untrusted content, not as configuration (MCP Primitives: Tools, Resources, Prompts in the Agentic AI domain).
- "The model picked the tool, so the model authorized it." Choosing an endpoint has never been permission to call it (Authentication vs Authorization).
- "The schema validates the arguments." The schema is a hint to the model and a contract for your validator. Only the validator enforces (The Three Validations).
- "These are internal functions, not an API." They are called by the least predictable client you have ever had.
- "We told it in the system prompt." A prompt influences a probability distribution. It does not deny a request.
- "Tool output is our own data, so it is safe to feed back." Your own database contains user-submitted text, which makes tool output an injection channel (Every Input Surface).
Operating it
- Per-tool call count, error rate, latency and authorization-denial count. A rising denial rate is either a prompt problem or an attack.
- Argument validation failures per tool — the direct measure of how often the model produces malformed calls.
- Idempotency-key hits on write tools: a non-zero rate means duplicate tool calls are happening and being absorbed (Duplicate Detection).
- Tool calls per conversation, as a distribution with a tail. The tail is where loops and abuse live.
- A span per tool call inside the request trace, with the tool name and the authorization outcome as attributes (Tracing From the Backend's Side).
- Nothing about the enforcement changes with scale; it is per call and it is cheap.
- Tool fan-out multiplies internal load: measure calls per user request, because one conversation can become dozens of internal requests (Cascading Failure).
- With many tools, a shared middleware for identity, authorization, validation and logging becomes necessary — the same argument as middleware for HTTP routes (The Middleware Pipeline).
- At very small scale the temptation is to skip enforcement "for now"; the resulting vulnerability is identical to skipping it on a public endpoint.
- Narrow tools are safer and less capable. A generic query tool is dramatically more flexible and gives the model an attack surface as wide as your data.
- Strict validation causes the model to fail calls it could almost have made correctly, costing retries and tokens. That is the correct trade.
- Approval gates on high-value actions slow the experience and are the only reliable control for irreversible operations (In-the-Loop vs On-the-Loop and Escalation in the Agentic AI domain).
- Per-tool middleware adds indirection and is the only way enforcement stays consistent across a growing tool list.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALIndependent of model provider, framework and transport — direct function calls, an MCP server or an HTTP tool endpoint all present the same obligations.
- FRAMEWORK-SPECIFICAgent frameworks differ in whether they give you a place to hook authorization and validation before dispatch. Where a framework offers no such hook, the enforcement has to live inside every handler, and the consistency problem is yours to solve.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — proving a tool refuses the calls it should, by testing the handler directly rather than through the model.