The Tool Gateway
- Explain why a model's tool call is a suggestion, not an action
- Name the five checks a gateway runs on every call
- Build a working gateway in about 40 lines of Python
- Return errors the model can actually recover from
The scare
Pip, our made-up support agent, has a Tuesday like this. A customer asks about a late order. Pip decides to be helpful and writes:
send_apology_voucher(customer="dana", amount=500)
There is no such tool. The developer's code, written in a hurry, does tools[name](**args), catches the KeyError, shrugs, and moves on. So far so harmless. But swap send_apology_voucher for a tool that does exist, and the code runs it with whatever arguments the model made up. Nothing checks that 500 is a sensible amount, or that Pip is even allowed to hand out vouchers.
This one is invented, but every team that has built an agent has met a version of it. Models make up tool names, mangle arguments, and repeat themselves.
Refresher: a tool call is just text
When an agent "calls a tool", the model does not call anything. It writes a small piece of structured text, like this:
model writes: {"tool": "get_order", "args": {"order": "A-1043"}}
│
▼
your code: reads that text, and DECIDES whether to run it
│
▼
real world: the order system gets queried
The middle box is the whole game. If it says "run whatever the text says," the model has your permissions. If it says "check first," you have a gateway.
The pattern
The Tool Gateway is a single function that every tool call must go through. Think of the front desk in an office building. Visitors do not wander to any floor. They are checked against a list, asked what they are here for, and given a pass for exactly one floor.
Five checks, in order:
| Check | Catches | Pip example |
|---|---|---|
| Registered? | Invented tools | send_apology_voucher does not exist |
| Schema valid? | Wrong types, missing fields | amount arrives as the string "lots" |
| Permitted? | Right tool, wrong agent | Pip is read-only for billing |
| Under the limit? | Loops, runaway calls | The 61st identical search this minute |
| Logged + timed out? | Hangs and silent failures | A slow API freezing the whole run |
Try it
Send five different calls through the gateway and see which check stops each one.
model → search_docs({ "query": "refund policy" })1. Registered? passed
Is this tool on the allowlist for this agent?
2. Schema valid? passed
Do the arguments match the declared types and ranges?
3. Permitted? passed
Does this agent's role allow this action on this resource?
4. Under the limit? passed
Is the agent inside its rate and budget limits?
5. Execute + log passed
Run it with a timeout; record inputs, output, duration.
All checks pass. The call runs and is logged.
Build it
import time
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class Tool:
fn: Callable
required: dict # arg name -> expected type
allowed_roles: set
max_per_minute: int = 30
@dataclass
class Gateway:
tools: dict = field(default_factory=dict)
calls: dict = field(default_factory=dict) # (role, tool) -> [timestamps]
log: list = field(default_factory=list)
def register(self, name, tool):
self.tools[name] = tool
def call(self, role, name, args):
tool = self.tools.get(name)
if tool is None:
return self._err(name, "unknown_tool", f"No tool called {name}. Available: {list(self.tools)}")
for arg, typ in tool.required.items():
if arg not in args or not isinstance(args[arg], typ):
return self._err(name, "bad_args", f"{arg} must be {typ.__name__}")
if role not in tool.allowed_roles:
return self._err(name, "forbidden", f"Role {role} cannot use {name}")
now = time.time()
recent = [t for t in self.calls.get((role, name), []) if now - t < 60]
if len(recent) >= tool.max_per_minute:
return self._err(name, "rate_limited", "Too many calls. Stop and report what you have.")
self.calls[(role, name)] = recent + [now]
try:
result = tool.fn(**args)
self.log.append({"tool": name, "args": args, "ok": True})
return {"ok": True, "result": result}
except Exception as e:
return self._err(name, "tool_failed", str(e))
def _err(self, name, code, message):
self.log.append({"tool": name, "ok": False, "code": code})
return {"ok": False, "code": code, "message": message}
gw = Gateway()
gw.register("search_docs", Tool(lambda query: f"3 results for {query}", {"query": str}, {"support", "admin"}))
gw.register("issue_refund", Tool(lambda order, amount: "refunded", {"order": str, "amount": (int, float)}, {"admin"}))
print(gw.call("support", "search_docs", {"query": "refund policy"}))
print(gw.call("support", "issue_refund", {"order": "A-1043", "amount": 42}))
print(gw.call("support", "send_apology_voucher", {"amount": 500}))
Run it. The first call works. The second is refused with forbidden. The third gets unknown_tool plus a list of tools that do exist, which is often all the model needed to correct itself.
When it goes wrong
- Checking only the tool name. Registered is the easy check. Most damage comes from valid tools with bad arguments.
- One shared rate limit. Limit per role and per tool, or one chatty tool starves the rest.
- Logging only successes. The refusals are the interesting rows. They show what the model is trying to do.
- Doing the checks inside each tool. Then each tool does them a little differently. Do them once, in front.
Where it connects
If your tools come from MCP servers, the gateway is the layer in front of them. MCP standardises how a tool is described and called; it does not decide who may call what. The MCP for Practical Builders course has a security chapter that goes hand in hand with this one. The next two patterns, permissions and approvals, plug straight into check 3 here.
- A tool call is text the model wrote; your code decides whether it runs
- A gateway is one function every call passes through: registered, valid, permitted, within limits, logged
- Return specific errors so the model can fix its own mistake
- Do the checks once, in front of the tools, not inside each of them
- A model calls
send_invoicebut onlycreate_invoiceexists. Which check catches it, and what should the error message say? - Pip works fine, but on one bad afternoon searches the same query 200 times. Which check was missing or too loose?