---
title: "ADR-201: A consumer can ask which tools a run will not be offered"
manual: "TYPO3 LLM Extension"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-201@main"
source: "Adr/Adr201TheToolsARunIsNotOffered.rst"
rendered: "2026-09-24T12:49:43+00:00"
---

# ADR-201: A consumer can ask which tools a run will not be offered {#adr-201}

-   *Status:* Accepted
-   *Date:* 2026-09-23
-   *Authors:* Netresearch DTT GmbH

## Context {#adr-201-context}

A model sees the tools it is offered and nothing else. The composite gate
([ADR-094](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-094@main)) drops a tool that is disabled, admin-only for a
non-admin, outside the configuration's tool groups, or above the provider's
trust-zone ceiling — and the model cannot tell that tool from one that does
not exist. Asked for what a dropped tool does, it says there is no way to do
it, and the user is sent nowhere.

On the Netresearch demo a configuration's tool groups held back sixteen
developer tools for six runs (demo conversation 104). Asked "which tools may
we NOT use?", the model answered that it saw no list of blocked tools. The
gate had recorded all sixteen refusals as governance events; nothing handed
them to the model.

`ToolCallPolicyInterface::explain()` already returns a
`ToolPolicyDecision` with the reason for every tool it is asked about.
Called without a list it asks about the globally ENABLED tools only, so a
tool an administrator switched off never appears — and that is the most
common reason a user cannot use one.

## Decision {#adr-201-decision}

`UnavailableToolsResolverInterface` (`@api`, public in the container)
runs the gate over every REGISTERED tool for one configuration and one
backend user and returns the decisions that refuse, each carrying the tool
name and a `ToolDenialReason`: `toolDisabled`, `requiresAdmin`,
`configurationGroup` or `trustZone`.

The ordering of the gate is unchanged: a tool that is both disabled and above
the ceiling reports `toolDisabled`, so the list never reveals a trust-zone
axis to a caller an earlier gate already stopped.

A builtin tool's trust-zone refusal while enforcement is in `observe` mode
([ADR-115](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-115@main)) is not listed: the tool IS offered in that mode, so
it is available. Enforcing, the same tool is withheld and listed with
`trustZone`. A remote tool never benefits from observe mode — the gate
enforces the ceiling on it in both modes (`ToolCallPolicy::decide()`, per
ADR-115) — so a remote tool above the ceiling is listed with `trustZone`
whatever the setting says.

Remote tools — the ones an MCP server contributes ([ADR-116](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-116@main)),
named `mcp_*` — are listed for administrators only. Their names come from
operator configuration rather than from this extension, and an editor has no
use for the catalogue of a server they cannot configure; an administrator
does, to tell a tool the gate holds back from one that is missing. Builtin
tools are listed for everyone.

What the consumer does with the list is its own decision. The chat of
`nr_mcp_agent` puts it into the system prompt, compact — one name and one
reason per line — so the assistant can answer "there is a tool for that, it is
not enabled for you, ask an administrator".

There is no reason code for a missing record permission. The gate decides per
tool, before any argument exists; whether the user may read or write a given
table or page is decided by the tool itself when it runs, and reported in its
result.

## Consequences {#adr-201-consequences}

✓ A consumer can tell the model what exists and why it is not available,
without re-deriving any gate.

✓ The list and the gate cannot drift: the list IS the gate's output.

✕ The model, and through it the user, learns the names of builtin tools they
cannot use, including admin-only ones and the fact that a trust-zone ceiling
applies. Remote tool names reach only an administrator's model.
Tool names and reason codes are policy facts, not instance data
(`ToolPolicyDecision::message()` says the same of its own text); the
consumer decides whether to pass them on.

✕ One gate evaluation per registered tool per call. The enabled set (two
small queries, the tool-state and group-state overrides) and the
configuration's allow-list are resolved once per call and shared by every
evaluation, which `ToolCallPolicyInterface::explain()` now does for
every caller.

## Revisit when {#adr-201-revisit}

A gate is added that decides per user and per tool before a call — then it
needs its own `ToolDenialReason` case, and this list reports it without
change.
