ADR-0024 — MCP-tool governance: re-home each concern to its natural surface
Status: Accepted (founder-approved 2026-07-21, issue #1988) Supersedes: the (b)-overlay recommendation in the architect finding on #1988 Refs: #288 #1421 #1732 #1744 #1862 #1864 · ADR-0019, ADR-0021 (T1 kill-switch, T2 approval), ADR-0022 (team gateway)
Context
Investigation on #1988 (architect layering finding + PM requirements finding) confirmed the founder's read: the worker-side GovernanceService/#288 default-deny that denied betadev-refund-mcp__refund is pre-MCP residue. #1421 (2026-06-17) authored a tool_call default-deny as the only tool PEP of its era; ADR-0021/0022 (2026-07-06 / 07-10) then re-implemented tool authorization at the team gateway edge (internal/gateway/mcpproxy/dispatch.go) as the canonical surface. This left #288 firing first, default-denying, with no authoring UI, via a taxonomy (tool_call/bare-name) that no seeded policy matches (cascade_query.go:91-92 vs tool_action/tool: from WF-30). The architect proposed keeping #288 as an optional deny/HITL overlay (option b). The founder chose instead to re-home each concern to its natural surface — this ADR specs that model precisely. It is a settled decision, not a re-litigation.
Decision
D1 — Authorization is unchanged and canonical. An agent may invoke an MCP tool iff its team has an approved binding to the server (mcp_server_units.approval_status='approved' + Manager approval, ADR-0021 D8 / ADR-0022) and mcp_servers.status='approved'. Team binding + cascade + Manager approval remain the sole grant surface. No change.
D2 — #288 exits the MCP tool path. The autonomous worker loop stops calling GovernanceService.Check for MCP-namespaced tool calls (identified by the {server}__{tool} / mcp:<server_id> form, ADR-0021 §5). The agent-worker execute_tools node skips the Check when the tool name is MCP-namespaced; the gateway edge becomes the only PEP for those calls. #288 KEEPS all other callers, unchanged: llm_request pre-LLM gate; coordinator / non-MCP agent-action governance; internal/runtime/security/pipeline.go; internal/mcp/middleware/middleware.go (the proxy plugin-chain guardrail — distinct from authorization); the workflow interpreter tool_action/tool: path (WF-30, goldenflow-seeded); a2a_egress governance (#1473); and clearance/requires_approval rows on those non-MCP paths. The only dropped path is action_type='tool_call' + MCP-namespaced target, autonomous-loop origin.
D3 — HITL / requires-approval → gateway edge, per-team. requires-approval varies by team, so it hooks at the chokepoint internal/gateway/mcpproxy/dispatch.go, alongside the existing edge gates (AuthorizeAggregated → #1744 allowlist → #1732 CEL). A per-(team,server,tool) requires_approval/escalate verdict at the edge creates a governance_approvals row (T4-7/#1443) and returns a distinct approval-required signal, not a silent deny. Authoring: the team-tier ToolPolicyService (D5). An ApprovalCreator is wired into the edge guardrails Engine so a matched escalate creates a real approval rather than degrading to fail-closed deny.
D4 — Org kill-switch is hierarchical: L5 registry kill (org-wide) + Manager subtree-withhold (span-of-control).
D4a — L5 org-wide kill-switch → MCP registry status (largely already built). ADR-0021 T1/#1715 already ships SetMCPServerStatus flipping mcp_servers.status between 'approved' and 'disabled'; the status='approved' predicates in the resolver (group_store.go:277, proxy_upstreams view store.go:52) already make a disabled server vanish from every cascaded team; audit exists (migration 137 mcp_server_status_audit). Founder answer to the gating question: gate at L5/org-admin (add L5 as an authorized actor at service.go:682). Remaining work: surface the disable control on the registry page + widen the RPC actor check to L5.
D4b — Manager subtree-withhold (new). A Manager at unit X may withhold an MCP server for X and every unit in X's subtree, without touching sibling subtrees or the org root. Mechanism: add mcp_server_units.binding_kind (default 'grant', new value 'withhold'). Resolution: (1) add binding_kind='grant' to each of the three grant SELECTs (mcpHomeForUnits direct/ancestor/sibling in resolver_axes.go, the proxy_upstreams view, group_store.go:277); (2) a new subtract-after-union pass in mcpHomeForUnits collects server ids with binding_kind='withhold' on any unit in the anchor's own chain ({anchor} ∪ ancestors) and subtracts them — so a withhold at X enters iff X is ancestor-or-self of the resolving team, scoping exactly to X's subtree. EffectiveMCPServerIDsForUnit inherits the subtract; the BYO/edge mirror in dispatch.go applies the same subtract. Actor: the unit's Manager (not L5). Audited; surfaced in the effective-access matrix as reason manager_subtree_withheld (D9).
D5 — Guardrails → existing three-tier ToolPolicyService (#1732), enforced at the edge (#1862). registry/server-tier → team-tier → agent-tier, most-specific-wins, at dispatch.go. The engine is built; the gap is authoring UI at all three tiers. HITL (D3) and any org deny-floor are expressed as ToolPolicyService verdicts, not #288 rows.
D6 — New registry-level clearance floor. mcp_servers gains min_clearance INT (new migration; no clearance column exists today — the existing entitlementClearanceFloor/tenantEditMinClearance gate RPC access, not agent-tool access). Defence-in-depth at two points: (a) snapshot-resolve drops the server from an agent's effective set when agent.clearance < mcp_servers.min_clearance; (b) a gateway-edge mirror check in dispatch.go before AuthorizeAggregated, covering BYO runtimes. Registry UI: a min_clearance field on the server page (L4 edit floor).
D7 — Per-agent MCP selection at creation. The RegisterAgent.tools narrow-only, server-UUID allowlist (#1744) exists in the API but the creation wizard doesn't expose it, and the team-page effective-access toggle writes but does not persist (bypassed by the v2.4 resolver / dead .tools field). Spec: (a) the creation wizard exposes MCP-server selection (narrow-only, from the team's approved set); (b) fix the toggle to write the resolver-honoured axis (freezeResolvedTools intersection) or make it read-only truth that deep-links to the team binding. Granularity: keep the server-level allowlist (#1744) + use the agent-tier ToolPolicyService (D5) for tool-level narrowing — reuses two already-built pieces, no schema change.
D8 — Vocabulary cleanup. With MCP out of #288 (D2), the tool_call/tool_action + bare-name/tool: mismatch is moot for MCP. The workflow path keeps its seeded tool_action/tool: policies (no dedup needed). The only loose end is autonomous-loop non-MCP built-in tool calls that still emit tool_call/bare-name with no seeded match: either reconcile them onto the tool_action/tool: taxonomy so authored policies can fire, or declare non-MCP built-in tools out of scope for #288 governance for now. Recommended as a small follow-up; not a blocker.
D9 — The effective-access matrix resolves denial reasons (diagnosability). For every agent×server cell the matrix returns a machine-resolved {verdict, reason}; when denied, a reason enum: org_disabled (D4a), not_bound (D1), manager_subtree_withheld (D4b), clearance_floor (D6), creation_allowlist (#1744, D7). Compute by extending GetTeamEntitlementMatrix to evaluate the five gates most-global-first and return the first that denies. No new enforcement surface — it reports the same gates the resolver/edge enforce, so it can never disagree with runtime.
Consequences
- Reuse (≈0 SP): kill-switch status mechanism + resolver predicate + audit (D4a); ToolPolicyService 3-tier engine + edge enforcement (D5);
RegisterAgent.toolsallowlist API (D7). - Fix: worker MCP-namespaced skip of #288 (D2); edge HITL hook (D3); effective-access toggle persistence + label (D7); non-MCP tool taxonomy reconciliation (D8).
- Build:
min_clearancemigration + dual enforcement + UI (D6); registry disable control UI + L5 actor widen (D4a); Manager subtree-withhold —binding_kindmigration + grant-predicate updates + subtract pass + edge mirror + Manager UI (D4b); matrix denial-reason resolution + client render (D9); creation-wizard MCP selection (D7); ToolPolicyService authoring UI at 3 tiers (D5, largest item). - Removed: #288 as an MCP-tool PEP (D2). Nothing else removed.
- PM's legitimate-needs list, re-homed: org hard-DENY/kill-switch → D4a (registry status, L5) + D4b (Manager subtree-withhold) + D5 (org-tier deny policy); HITL → D3 (edge); clearance-gating → D6 + D5; cascading arg/response guardrails → D5; budget/audit → unchanged (#288 non-MCP paths + edge audit); diagnosability → D9. None lost.
Rollout (safe ordering)
Hazard check: the autonomous-loop tool_call/bare-name taxonomy has zero seeded/authorable matches (the vocab mismatch means every MCP tool call already default-denies), and there is no requires_approval policy on any tool taxonomy today. So removing #288 from the MCP path cannot regress an existing approval flow; D2 is safe to land independently. Order:
- PR-1 (D2 + D3): worker MCP-namespaced skip (unblocks the refund) + edge HITL, shipped together so the requires-approval capability is not lost.
- PR-2 (D6): registry clearance floor.
- PR-3 (D4b): Manager subtree-withhold.
- PR-4 (D9): entitlement-matrix denial reasons (renders D4b/D6).
- PR-5 (frontend): agent-creation wizard MCP selection + effective-access toggle fix + D9 render.
- PR-6 (frontend): registry L5 clearance-floor + org-disable control + 3-tier guardrail authoring UI.