ADR-028: On a Front Door, the Tool-Access Policy Is the Invoke Gate for Upstream Tools
Status: Proposed Date: 2026-09-13 Authors: MCP Hangar Team
Context
ADR-027 recorded that the two
paths to an upstream tool authorize a call differently. Paths and line numbers
below are in the published mcp-hangar 2.19.1 wheel, relative to the
mcp_hangar/ package.
hangar_call validates each call against the fleet, then runs
_authorize_calls, then hands the batch to BatchExecutor.
- Validation. It checks that the named server or group and the named tool
exist, and it runs before any authorization (
server/tools/batch/__init__.py:490). _authorize_callsis called at:518.- With auth off, it allows every call (
:131-:132). - With auth on, it refuses the whole batch when the principal is missing or
anonymous (
:144). Otherwise it requirestool:invokeper call, with the bare tool name as the resource id, and fails closed (:163-:189). tests/live/test_t2_auth.pypins this over HTTP: avieweris refused and adeveloperis not.
- With auth off, it allows every call (
The flat front-door path resolves the requested name against the caller's
flat map before anything else. That map is built by _build_flat_map from the
governed decision, is_governed_allowed, which applies the tool-access policy
and the withdrawal scopes with each group member collapsed into its group. It
also applies two header filters: x-mcp-header annotations must be valid, and
the operator's header_exposure block must allow the tool.
A name outside the map is -32601. A denied item is answered exactly like a
missing one, which keeps the front door from being a cross-tenant enumeration
oracle (mcp-hangar#905). A name inside the map dispatches through its group
(fastmcp_server/flat_tool_projection.py:944), and then BatchExecutor runs
its gates (server/tools/batch/executor.py:1795-:1808).
None of those steps checks an RBAC permission.
The two paths are served in different modes today, so no surface shows both.
mcp-hangar#1370 changes that. Its default front-door surface is
hangar_find, hangar_call and the management tools a caller is permitted,
with tool_projection.flat as the opt-in, so one front door would carry both
rules.
An HTTP principal holding viewer, which lacks tool:invoke, could then call a
tool by its flat name and be refused the same tool through hangar_call. A
discovery tool that lists what the projection holds would show tools that
hangar_call then refuses. ADR-022
set the rule that shown means callable for the management surface. The same
reasoning applies here.
On stdio, tool:invoke decides nothing today
(ADR-026):
- With auth off,
_authorize_callsallows every call. - With auth on, it looks for the principal on an HTTP request that a pipe never carries, so it refuses a stdio caller as anonymous.
The split is therefore an HTTP concern.
Two properties of tool:invoke matter for what replaces it.
- A grant is per tool name, not per server. A permission names a resource id
(
domain/value_objects/security.py:141, matched at:151-:166), and_authorize_callspasses the bare tool name as that id. A grant therefore covers that name on every server, while the tool-access policy is keyed by server, group and tenant, never by principal. - Continuations are not bound by it.
hangar_fetch_continuationandhangar_delete_continuationhand back the tail of a truncatedhangar_callresult. They are gated bytool:invokeagainst every resource id, and they accept a grant held within any tenant (server/tools/tool_permissions.py:111,:352). The cache stores the response under its continuation id alone (infrastructure/truncation/memory_cache.py:82,infrastructure/truncation/redis_cache.py:84). Sotool:invokelimits who may redeem an id, and nothing binds a continuation to the caller that produced it.
The flat path never hands a continuation id out. It returns only the call's
result (fastmcp_server/flat_tool_projection.py:997). Only hangar_call's
result formatting carries the id (server/tools/batch/executor.py:1782-:1783),
and its docstring tells the client to redeem it with hangar_fetch_continuation
(server/tools/batch/__init__.py:410-:412).
Decision
1. On a front door, hangar_call is gated exactly as a flat call is
In front_door, a call that hangar_call carries to an upstream tool passes
three gates:
- It may name only what the caller's flat projection holds. That is the same
map, built by the same generation (
mcp-hangar#1367), with each group member collapsed into its group. Any other target gets the answer a missing tool gets, and a denied target is indistinguishable from a missing one. - It then passes the same
BatchExecutorgates as a flat call. - It does not pass
_authorize_calls, sotool:invokeis not required.
_authorize_calls also refuses an anonymous caller. On a front door the
resolver's no-tenant deny (mcp-hangar#236) does that instead. The deny holds
because a tenant reaches the identity context from only two places: an
authenticated principal (fastmcp_server/asgi.py:139-:152) or a declared
stdio principal (ADR-026). Anything that adds a third place has to preserve this.
With those three gates, one rule decides:
- what the flat surface lists;
- what a discovery tool such as
hangar_findmay list; - what either path will call.
That is ADR-022's rule that shown means callable, applied to the invoke path.
2. egress keeps _authorize_calls
In egress, hangar_call is unchanged. There it is the only invoke path, and
tool:invoke is the only per-principal control over which upstream tools a
principal may call. The tool-access policy there is keyed by tenant, and a caller
in egress may carry no tenant at all (ADR-027).
Scope: continuation tools are not upstream tools
This decision covers calls to upstream tools. hangar_fetch_continuation and
hangar_delete_continuation are outside it. They keep tool:invoke, and the
front door does not project them, as today.
Removing tool:invoke from them would leave the continuation id as the whole
control. Binding a continuation to its caller is a separate change, and this
decision does not make it.
Consequences
Positive
- On a front door, listing and calling follow one rule. No path calls a target the surface did not list, and none refuses by permission a target it did.
- A declared stdio principal is unaffected.
tool:invokenever decided a stdio call. - Nothing a deployment runs today changes. No front door serves
hangar_callyet, andegressis untouched. The rule takes effect with the surfacemcp-hangar#1370introduces.
Negative
- A front door enforces no per-principal tool grant. A custom role that
grants
tool:invokeon some tool names governs nothing there. The flat path never honoured such a grant, so no running front door loses anything.- An operator who moves from
egresstofront_doorwith such roles does lose it, and the gateway does not say so. - The translation is not one-to-one. The grant is by bare tool name across every server, while tenant policy is per server.
- The upgrade note for
mcp-hangar#1370's surface has to say both.
- An operator who moves from
hangar_callauthorizes differently by mode:tool:invokeinegress, not on a front door. The same principal making the same call can be allowed by one gateway and refused by another.- The agent role ADR-022 recommends grants nothing on a front door. That
role holds
tool:invokeand nothing else. ADR-022 also kepthangar_calloff the front door as "theegressway in".mcp-hangar#1370reverses that, and this decision assumes the reversal. - Truncated results become unredeemable on a front door. Once
hangar_callis served there, a truncated result will carry a continuation id for a tool the front door does not serve. A front-door client still cannot fetch the rest of a truncated result, and it will now be told how to try.
Neutral
- On a front door, which tools a caller may use varies per tenant and never per
principal within a tenant. That was already true of the flat path, and this
decision extends it to
hangar_call.
Alternatives Considered
1. Require tool:invoke on the flat path as well
- Rejected: It is the stricter rule, and it keeps per-principal grants
working. It also changes what every running HTTP front door does: a principal
without
tool:invokeloses every upstream tool it can call today. The control it preserves has never been enforced on a front door.
2. Keep both rules
- Rejected: One surface would list tools that one of its paths refuses.
3. Give the tool-access policy a per-principal scope
- Deferred: It would bring per-principal restriction back to the front door inside a single rule. Nobody has asked for it, and it is a change to the policy model rather than to the invoke gate.
References
- Epic:
mcp-hangar#1370. The question this follows from:mcp-hangar#1371. The projection thathangar_callnames from:mcp-hangar#1367. - Evidence: the
mcp-hangar2.19.1 wheel from PyPI, cited in Context. Tests:tests/live/test_t2_auth.py(hangar_callrefuses aviewerover HTTP). - The fail-closed default for a caller with no tenant:
mcp-hangar#236. A denied item answered like a missing one:mcp-hangar#905. - Related decisions:
ADR-022
(shown means callable;
hangar_calland the continuation tools are the invoke path), ADR-026 (the stdio principal and itsviewerdefault), ADR-027 (row 8: the two paths authorize differently).