TOOL AUTHORISATION

Control which tools an AI agent may actually call

Grants re-checked at execution rather than at the list, and a descriptor that changed is quarantined.

You control which tools an agent may call by putting one endpoint in front of every upstream Model Context Protocol server and authorising the call itself rather than the list: tools reach an agent namespaced server.tool and filtered to that agent’s grants, and every invocation is authorised again from scratch, because filtering a list is a usability feature and a client can send a name it was never shown. Grants are deny-by-default and action-level — a support agent holds an allow on tool:orderdb/get_details while tool:payments/issue_refund is simply absent and therefore denied — an explicit deny beats every allow wherever it is written, and a delegation chain intersects rather than unions, so a low-privileged agent gains nothing by routing work through a higher-privileged one. Each tool’s name, description and input schema are hashed when an operator approves it and re-checked on every catalogue refresh, so a descriptor rewritten upstream is quarantined and refused until a human approves it again. Permissions decide the tool and policy decides the argument: a rule saying refunds over £200 need a person is a policy, because this layer knows the tool and not the amount. What none of it reaches is a tool the agent holds and calls from its own process.
Grant vocabulary
tool:server/tool, wildcards escaped, an explicit deny beats every allow
Checked twice
The list is filtered; the call re-authorises independently
Descriptor pin
SHA-256 over name, description and input schema, re-checked each refresh
Inspection bounds
Depth 32, 5,000 nodes, 65,536 characters — crossing one fails closed
What is not governed hereA tool the agent calls from its own process, never routed through it
On this page
before the fix

The list in the client config, and the description that changed in week six

Tool access usually starts as a list of servers in a client configuration file. That file lives on a developer’s machine or in an agent’s repository, it is edited by whoever is shipping, and it grants whole servers rather than actions — so an agent that needs one read against the order database receives the refund endpoint sitting next to it. Nobody outside the team that wrote it can say what an agent may do, and the answer changes with the next commit.

The next attempt is a gateway that filters the tool list. That reads as access control and is not: a client can send a call for a tool name it was never shown, and an agent under the influence of a paragraph of retrieved text is precisely the component that will try. The filtered list is a usability feature — it stops a framework picking a model or a tool that will be refused three lines later — and the enforcement point has to be the call.

Then there is the surface almost nobody governs, which is the tool descriptor itself. A tool’s name, its description and its input schema are text the model reads and obeys, so an upstream server that changes them — through a compromise, a dependency swap, or an ordinary release nobody told you about — rewrites what your agent believes it is doing without touching a line of your code. The version that matters is the patient one: a tool that behaved for six weeks and then acquired a new sentence in its description.

The last is the argument. Teams reach for a permission model to express refunds over £200 need approval and discover that a permission carries an effect, a resource pattern and a set of actions, and nothing else. Putting the amount into the grant sounds attractive until somebody has to attest to it: a conditional grant that must be simulated before anybody understands it is not something a named reviewer can honestly sign during a recertification.

the procedure

How to actually do it

Each step is something you can go and do. Where a step depends on a decision somebody has to make rather than a setting somebody has to change, it says so.

  1. 01

    Register each upstream server behind one endpoint

    A server row carries a name, a destination and the name of an environment variable holding its Authorization header — never the credential, which is read out of the process environment when the connection is made, never stored on the row and never logged. The destination must pass a host allowlist and may not carry credentials in its userinfo, and the variable must match an allowed prefix and may never name one of the deployment’s own reserved secrets.
  2. 02

    Write grants as an allowlist of actions, not of systems

    Grant the specific tools each agent’s declared purpose requires, using tool:server/tool with wildcards where a whole namespace is genuinely intended. Everything else is denied by default. Expect the exercise to surface grants nobody can justify — that is the exercise working, and it is the layer that still holds when a detector misses an injection.
  3. 03

    Point the client at one URL and change nothing else

    Replace every upstream server in the client configuration with the gateway’s endpoint and the agent’s own token in an Authorization bearer header. Tools then arrive namespaced server.tool and filtered to that agent’s grants. The transport is Streamable HTTP — a POST carries one JSON-RPC message, a GET opens the notification stream, a DELETE ends the session — and batching is refused outright, having been removed from the specification.
  4. 04

    Pin the descriptors you have reviewed

    Approving a tool hashes its name, description and input schema into one SHA-256 over a canonical serialisation, so two descriptors differing only in key order hash identically and every refresh does not report drift. From then on a change quarantines the tool: hidden from the agent-facing catalogue, refused on call, recorded on the pin with both hashes, published as an event and raised as a high-severity discovery finding.
  5. 05

    Express the argument rules as policy, staged in shadow

    A tool-call trigger takes a tool name pattern plus argument conditions — a dot path and one of eight operators — and an action of block, require approval, redact, warn or suspend the agent. Run it in shadow mode first so you learn which team’s work it stops before it stops it, and scope it by agent id, team or tag rather than leaving it global by accident.
  6. 06

    Read the refusals rather than only the successes

    A denied call still opens a trace and closes it as blocked, with the arguments masked irreversibly first. An agent repeatedly proposing a tool it does not hold, or one a policy keeps stopping, is exactly the pattern an investigation needs — and it would be invisible if refusals were dropped on the floor.

Permissions decide the tool; policy decides the argument

An action is allowed only when at least one permission on at least one of the agent’s roles explicitly allows it, and none denies it. There is no implicit grant anywhere in the evaluator: an agent holding no roles is refused, a role carrying an empty permission list grants nothing, and a resource no permission names produces a refusal whose recorded reason ends in deny by default — which is the string an operator will search the flight recorder for when an agent starts failing.

The deny path returns before any allow is settled on, which makes precedence independent of ordering. A guardrail role that denies one refund tool wins whether it is listed before the broad role that grants the payments namespace, after it, or inside the same role. That is what makes a subtractive guardrail a pattern you can rely on rather than a race: you can grant a namespace to a team and remove one action from one agent without rewriting or duplicating the broad grant.

Matching is deliberately dull. A resource pattern is a literal string in which an asterisk matches any run of characters including a slash; every other regular-expression metacharacter is escaped before the pattern compiles, so an operator who types a full stop gets a full stop rather than an accidental wildcard, and a permission on tool:orderdb/get_details does not match tool:orderdbXget_details. Actions are compared case-insensitively, and everything is evaluated as invoke today — the action field is genuinely matched, so a permission scoped to read denies an invoke on the same resource, but a finer verb set is reserved rather than issued.

Delegation is where a permission model usually leaks, and here it intersects. When one agent delegates to another, the effective set is the intersection of every agent in the chain, and the evaluator refuses at the first link that does not allow, recording which link it was and out of how many. The union is never taken: an orders agent and a payments agent that delegate to each other can jointly do nothing. That is inconvenient by design, because the failure mode of an intersection is under-privilege, which surfaces as a ticket somebody investigates, while the failure mode of a union is an escalation with an audit trail that looks entirely legitimate. The chain arrives as a request header and is asserted rather than proven, which the intersection is what makes acceptable: a forged chain can only add links, and every added link must also allow, so forging it buys an attacker strictly less than sending none.

Namespace wildcards do not leak
tool:orderdb/* matches every tool on that server and nothing on any other. The bare wildcard permission — an asterisk on an asterisk — allows everything, and it is the one grant a reviewer should be able to find in seconds.
Roles are flat
A role is a name, a description and a list of permissions, with no parent and no inheritance, so a role meant as a superset of another has to restate it. That is more typing and less to reason about when somebody asks what an agent can actually do.
Permissions do not read arguments
Refunds over £200 need approval is a policy, matched on a tool name pattern plus conditions on argument values. Keeping the two apart is deliberate: a permission has to be readable by a reviewer in one line.
A role cannot be deleted while anything references it
The reference check runs inside the same transaction as the deletion and is serialised against the writers that assign roles, and the refusal reports how many agents, seats and group mappings still hold it. Deleting a role out from under a running agent would be a silent permission change.

The tool descriptor is part of the prompt, so it is pinned

Pinning a tool records a SHA-256 over its name, description and input schema, canonicalised with object keys sorted and undefined dropped so that harmless key reordering does not manufacture drift. Every catalogue refresh recomputes and compares — and a read is what triggers a refresh once the sixty-second snapshot has gone stale or the stored pins have changed — which means the administrative view of a server and the integrity check on it are the same operation rather than two things that can disagree.

Unpinned is deliberately not a block. Pinning is an explicit approval action and a gateway that refused every unreviewed tool would simply not be adopted, so an unpinned tool stays usable and its description is amended, for the model to read, with a note that no operator has approved it and its descriptor is unverified. Drift is the opposite case — an approved thing changed underneath you — so it quarantines immediately and is refused on call with an explanation the model can act on.

What the console can show you is bounded by what is stored, and it says so rather than implying otherwise. Token Observe keeps the hash of the approved descriptor rather than its text, so a quarantine screen genuinely cannot show a diff of what changed. Rather than let an operator approve on the false premise that they have read one, the screen states that the previous wording cannot be shown and asks a different question: this is what the tool says now, and is that what you intend your agents to obey.

The pin is then checked once more than you would expect. Governance and a human approval can take long enough for another replica to quarantine a tool or reclassify it, so immediately before the first byte leaves for the upstream, the caller’s exact snapshot — server, tool name, descriptor hash, pin status, effect classification — is compared against a fresh durable read, and any transition refuses the call. The request can restart under the new authority; what it may not do is reinterpret an already-governed raw call as a contract-bound action, or the reverse.

Pinned
Approved, and the descriptor upstream still matches the approval. Listed and callable.
Unpinned
Nobody has approved it. Listed and callable, surfaced to operators for approval, and described to the model as unverified.
Quarantined
The description or input schema changed after approval, or an operator blocked it. Hidden from the agent-facing catalogue and refused until a human approves it again.
Missing upstream
Approved here, and the server no longer offers it. Reported rather than dropped, because an approved capability disappearing without a change request is itself evidence. Where the catalogue could not be read at all the state is reported as unverified, never as safe.

What a refusal looks like to the agent, and what it looks like to you

There are two refusal shapes and the choice between them is deliberate. A tool the caller cannot see returns the JSON-RPC protocol error for an unknown tool, and existence and visibility collapse into that one answer on purpose, because confirming that a tool exists is an inventory disclosure. A tool the caller can see but may not use right now returns a successful JSON-RPC response whose result is flagged as an error: the model reads it, can explain it to the person, and can choose a different action. A protocol error in that position surfaces to most clients as a transport fault the model never gets to reason about.

Every refusal carries machine-readable metadata beside the prose — a typed code, a retryable flag, and an approval id where one exists — and the protocol-error form carries the same object under its error data. The gateway also states the contract in the instructions it returns at initialisation, so a client that has been told none of this reads it from the server: tools are namespaced and filtered to this agent’s grants, a refused call returns an error result with that metadata, and a call gated on approval is retried by repeating it with the approval id in the request’s metadata field.

Bounds are refusals rather than truncations, and they land on different sides of the call. Arguments that cannot be fully inspected are refused before anything runs, because returning the untouched remainder would forward precisely the bytes that were never governed. A result that cannot be fully inspected is withheld after the tool has already run, and the message says which of the two happened, because the difference decides whether the agent should try something else or tell somebody. Non-text output — an image, audio, a blob, an embedded or externally fetched resource — is withheld for the same reason, since there is no bounded media decoding or OCR on this path.

One reach beyond the endpoint is worth stating precisely, because it is where an enforced control and a detective one get confused. The model gateway evaluates tool-call rules against the tool calls a model proposes in its response, before that response is returned, so a rule about refunds binds whether or not execution is routed through the tool gateway; on a stream the frames are held per index until the arguments parse as complete JSON, governed, and only then released. That is defence in depth rather than a guarantee, and the product says so in those words: it can only refuse a proposal it is shown.

the bit that remains

What this still does not solve

Doing everything above leaves a residue. It is smaller than what you started with and it is not nothing, and knowing its shape in advance is the difference between a control you trust correctly and one you trust too much.

  • A tool call that never reaches Token Observe is not refused by anything here. Evaluating proposed tool calls extends the reach to agents that execute tools in their own process, and it is defence in depth rather than a guarantee — an agent that routes nothing through the gateway is a detection problem rather than an enforcement one.
  • There are no time-bound or just-in-time grants. A permission stands until somebody edits the role, nothing expires on its own, and agent credentials are long-lived bearer tokens rotated by hand — which the product’s own architecture record names as exactly the standing-privilege pattern to be wary of.
  • An unpinned tool is not blocked, and a quarantine screen cannot show you a diff. Only the hash of the approved descriptor is stored, so what changed cannot be reconstructed; the console shows the descriptor as it reads now and says plainly that this is what it is showing.
  • The bearer token is the agent. Any process holding that string is that agent, with no cryptographic binding to a workload unless an operator has wired up workload identity, and the product carries that as a stated residual risk rather than hiding it.

If one of those residues is the thing that actually worries you, that is the conversation worth having rather than the one about the steps above it.

Talk it through

Why does the gateway check permissions twice?

Because filtering a tool list is a usability feature, not access control. A client can send a call for a tool name it was never shown, so the call path re-evaluates permissions from scratch rather than trusting that the name came off a filtered list. The two answers are also deliberately different: a tool the agent cannot see returns the protocol error for an unknown tool, without distinguishing does not exist from not yours, because confirming existence is an inventory disclosure; a tool it can see but may not use right now returns an error-flagged result explaining why, which the model can act on. Both are recorded on a trace.

What happens when an upstream tool’s description changes?

It is quarantined on the next catalogue refresh and refused until a human approves it again. Name, description and input schema are hashed when an operator pins the tool, and the hash is recomputed on every refresh; a mismatch hides the tool from the agent-facing catalogue, records the drift on the pin with both hashes, publishes an event and raises a high-severity discovery finding classified as egress, because an integration you send data to changed without going through change control. Unpinned tools stay usable on purpose and are described to the model as unverified.

Can a low-privileged agent get access by asking a higher-privileged agent?

No. When one agent delegates to another the effective permission set is the intersection of every agent in the chain, so the request is refused at the first link that does not hold the grant, and the refusal records which link that was out of how many. The union is never taken. The chain arrives as a request header and is asserted rather than proven, but because it can only add links and every link must allow, forging it buys an attacker strictly less than sending none — and a payload-bound approval covers the ordered delegation identities and their grants, so an approval obtained under one chain cannot be replayed under another.

Can a permission depend on the value of an argument?

No, and the separation is deliberate. A permission carries an effect, a resource pattern and a set of actions, and nothing else. The layer that reads argument values is the policy engine: a tool-call trigger takes a tool name pattern plus conditions on argument values, using a dot path and one of eight operators, and can block, require a human approval, redact, warn or suspend the agent. Permissions answer whether this agent may touch this tool at all, and that answer has to be readable by a reviewer in one line, because a conditional grant nobody can read is not something a named person can honestly attest to.

What is recorded for a single tool call?

One trace, opened before any verdict is reached, so a refused call is evidence too. Inside it: a tool-call event naming the server, the tool and its pin status with the arguments as they were actually sent, after redaction; a policy-decision event carrying the verdict, the reason and every match including shadow-mode ones; a redaction event with direction, mode and kinds but never values; and a tool-result event with the delivered content, the injection score, the heuristics that fired, the classes detected and the upstream duration. On a refused call the arguments are masked irreversibly first, because a call blocked for containing a secret must not write that secret into the evidence store.

get in touch

Describe the version of this you actually have.

The steps above are the general shape. Which of them matter, and in what order, depends on what your agents do and which of them worries you — say that and you will get a straight answer, including when the answer is that Token Observe is not what you need for it.

no form · no qualification step · no sales desk · the other three ways in