Pranav Srivastava

11 lessons

0/11 done
Lesson 2 of 11·12 min·Intermediate
112 min

The Tool Gateway

What you will learn
  • 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:

What actually happens on a tool call
  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:

CheckCatchesPip example
Registered?Invented toolssend_apology_voucher does not exist
Schema valid?Wrong types, missing fieldsamount arrives as the string "lots"
Permitted?Right tool, wrong agentPip is read-only for billing
Under the limit?Loops, runaway callsThe 61st identical search this minute
Logged + timed out?Hangs and silent failuresA slow API freezing the whole run

Try it

Send five different calls through the gateway and see which check stops each one.

Send a tool call through the gateway
model → search_docs({ "query": "refund policy" })
  1. 1. Registered? passed

    Is this tool on the allowlist for this agent?

  2. 2. Schema valid? passed

    Do the arguments match the declared types and ranges?

  3. 3. Permitted? passed

    Does this agent's role allow this action on this resource?

  4. 4. Under the limit? passed

    Is the agent inside its rate and budget limits?

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

Chapter summary
  • 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
Check your understanding
  1. A model calls send_invoice but only create_invoice exists. Which check catches it, and what should the error message say?
  2. Pip works fine, but on one bad afternoon searches the same query 200 times. Which check was missing or too loose?

Finished this lesson?

Mark it done — your progress is saved automatically.