MCP Tool-Level Authorization Design Proposal¶
Status: ABANDONED (2026-08-29) — do not retry on the shared gateway. Owner: home-ops Last updated: 2026-08-29
Postmortem — why this was abandoned (2026-08-29)¶
An implementation of Stages 2–4 (additive
mcp-authzlistener + AuthPolicy + Authorino wristband) was built, shipped, and caused a shared-gateway outage, then fully reverted. Two lessons, both load-bearing:
- The Kuadrant/Istio wasm-shim is fail-closed and gateway-wide. Attaching an AuthPolicy to the
mcp-gatewayIstio Gateway makes Kuadrant install a wasm-shim (oci://quay.io/kuadrant/wasm-shim) on that Gateway's Envoy. A WasmPlugin on a Gateway workload applies to all its listeners, and underFAIL_CLOSEa failed fetch makes every request 5xx. So a shim that can't load 503smcp/mcpstoo —sectionNamescopes matching, not fault isolation. The additive listener shares the Envoy, so it shares the failure domain. Proper isolation would require a separate IstioGatewayobject (its own Envoy). Confirmed via Istio's WasmPlugin API docs + istio#33858.- It was disproportionate. The whole effort existed to constrain one unattended agent (opencode) — which also has a
bashtool withworld:443egress, so MCP-authz only closed the smaller of two doors. Standing up Authorino + Limitador + DNS-operator + an OIDC client + a JWT rotator + a wasm-shim, and coupling the shared MCP path's uptime to that shim's fetch reliability, was the wrong-sized tool.The two proximate bugs were themselves fixable (a missing CNP egress for the gateway pod's OCI wasm pull to
quay.io:443; the ES256 signing-key Secret needed to live in thekuadrantnamespace, notmcp-system) — but fixing them only shrinks the outage window; it does not remove the shared failure domain.If per-tool authz is ever genuinely needed (multiple external clients needing cryptographic, claim-based, per-tool differentiation — not the case today), build it on a dedicated Istio Gateway, preload the wasm image into ZOT and pin by digest, and never attach it to the Envoy serving
mcp/mcps. For unattended agents, prefer the network-layer answer that.agents/instructions/claude-runner-routing.mdalready prescribes: a read-only-only surface + scoped RBAC, no mutating broker egress. The design below is retained for historical context only.
Original status: Proposal — research only, not implemented. Prerequisite for running any agent unattended against the MCP gateway.
Update 2026-07-26 — this is no longer a prerequisite for using the gateway, only for enforcing restrictions on it.
It was previously believed the gateway could not be used at all with a 65k-context model, because its
tools/listis 1,266 tools / 1.34 MB ≈ 334k tokens. Measurement showed opencode applies itstoolsfilter client-side, before the model call — an allowlisted agent sends ~12k tokens of schemas, and a filtered agent's request measured 18.8k input tokens with the entire gateway attached.However, enabling it in-cluster on 2026-07-26 still failed and was reverted the same day. The 18.8k figure came from a local
--purerun with plugins disabled. In-cluster theoh-my-openagentplugin injects six further MCP servers, and the baseline was already 53-57k input tokens before any gateway tools; ~12k of allowlisted kubectl pushed it pastmax_model_lenand vLLM returned 400. The context budget must be cut at the baseline before the gateway can be enabled at any allowlist size.Nothing in the threat model below changes. The allowlist is a context-budget control that happens to narrow reach; a session can still lift it. Everything this document says about unattended agents still applies, and the in-cluster server remains Plan C (interactive, Rob-only).
Problem statement¶
The MCP gateway federates every MCP server in mcp-system — 18
registrations at time of writing, including kubectl_*, omada_*,
ha_*, netbox_* and comfyui_*. Any client that reaches the gateway
can call any federated tool. There is no per-caller restriction.
Today that is tolerable because every MCP client is driven interactively
by Rob. It stops being tolerable the moment an agent runs unattended:
the in-cluster opencode server
(kubernetes/apps/ai/opencode)
runs a bash tool and has egress to the gateway, so a single
mis-planned session can mutate the cluster, the network, or Home
Assistant with nothing in the path to stop it.
The client-side gate in opencode.json:
is configuration, not enforcement. Any session can lift it at
runtime via select_tools. It reduces accidental tool use; it does not
bound a compromised or badly-steered agent.
HOMELAB-SPEC Layer 4 anticipates a guardian mode that gates destructive operations through a queue, but that queue substrate does not exist yet. Until it does, tool-level authz at the gateway is the only enforcement point available.
What the gateway already supports¶
Kuadrant's MCP gateway has a real tool-level authorization story. Three mechanisms, easy to confuse — only one of them is a security boundary:
| Mechanism | Set by | Boundary? |
|---|---|---|
tools: {"lovenet-gateway_*": false} in opencode.json |
the client | No — client config |
MCPVirtualServer.spec.tools selected via x-mcp-virtualserver header |
the client picks the header | No — client chooses its own view |
AuthPolicy matching x-mcp-toolname |
the MCP Router sets the header | Yes |
The third is the one that matters. The router extracts the tool name
from the JSON-RPC body and sets x-mcp-toolname itself, so the client
cannot forge it. Authorino then evaluates a CEL predicate against the
caller's token claims:
authorization:
'tool-access-check':
patternMatching:
patterns:
- predicate: |
request.headers['x-mcp-toolname'] in (...token claims...)
tools/list filtering is enforced by the same decision, via an
ES256-signed "wristband" header (x-authorised-tools) that Authorino
issues and the broker validates — so a restricted caller cannot even
enumerate tools it may not call.
Blocker: the policy stack is not installed¶
AuthPolicy is not a known resource type in this cluster:
Only the mcp.kuadrant.io CRDs are present (MCPGatewayExtension,
MCPServerRegistration, MCPVirtualServer). The Kuadrant operator and
Authorino — which provide AuthPolicy and the authorization service —
are not deployed.
The operator is staged in-repo at
kubernetes/apps/kuadrant/kuadrant-operator
(chart kuadrant-operator 1.5.2 — an umbrella pulling in authorino,
limitador, and dns-operator) but is commented out of the top-level
kustomization:
Git history shows an archive-kuadrant commit that was later reverted,
leaving the app present but disabled. No rationale is recorded, and
per the repo's suspend/disable convention this should not be re-enabled
without Rob's explicit instruction. Recovering that rationale is step 0
— if Kuadrant was disabled because it conflicted with Envoy Gateway or
Istio, this whole design needs rethinking.
The migration hazard¶
AuthPolicy attaches to a Gateway listener:
and applies to all traffic arriving at that listener — including
in-cluster traffic, because in-cluster clients address the istio gateway
Service directly (mcp-gateway-istio.mcp-system.svc.cluster.local:8080).
That is the hazard. Current in-cluster MCP clients send no identity at
all: langgraph-agents uses a bare MCP_GATEWAY_URL with no token,
and the CNP mcp-gateway-istio-allow deliberately permits
fromEntities: cluster on 8080 for exactly this. Attaching an
authenticating AuthPolicy to the mcp listener would break every one of
them at once.
The mcp-gateway Gateway already has two listeners (mcp and mcps),
which makes a non-breaking path available.
Proposed approach¶
Staged, each stage independently revertible.
Stage 0 — recover the Kuadrant rationale¶
Determine why # - kuadrant is commented out. If it was cost,
complexity, or "not needed yet", proceed. If it was a conflict with
Envoy Gateway or Istio, stop and redesign. Rob's call; not an agent
decision.
Stage 1 — enable Kuadrant + Authorino¶
Uncomment the app, let it reconcile into the existing kuadrant
namespace (manifest and baseline network policy already exist). Verify
AuthPolicy and AuthConfig register and that no existing traffic
changes — installing the operator alone attaches no policy.
Rollback: re-comment, Flux prunes.
Stage 2 — dedicated authenticated listener¶
Add a listener (e.g. mcp-authz) to the mcp-gateway Gateway and
attach the AuthPolicy with sectionName: mcp-authz. Existing clients
stay on mcp and are untouched.
This is the key decision: an additive listener rather than a policy on
the shared one. Slightly more moving parts, but it means a mistake in
the policy cannot take down langgraph-agents, Open WebUI, or Rob's own
Claude Code sessions.
Stage 3 — identity for opencode¶
Create an Authelia OIDC client for opencode and mint tokens with the
client-credentials flow. The rotation machinery already exists and is
proven: mcp-gateway-jwt-rotator is a CronJob that trades client
credentials for a token and writes it to a Secret. Clone the pattern
rather than inventing one.
The token's claims carry the allowed tool list that the AuthPolicy CEL
predicate matches against x-mcp-toolname.
Stage 4 — cut opencode over and verify negatively¶
Point opencode's mcp.lovenet-gateway.url at the authenticated listener
with the bearer token.
Verification must be negative, not just positive:
- an allowed read-only tool (e.g.
kubectl_get_pods) succeeds - a mutating tool (e.g.
kubectl_kubectl_apply) is denied, and is denied even when the session explicitly callsselect_toolsto enable it tools/listdoes not enumerate the denied toolslanggraph-agentsand Open WebUI still work unchanged
The second bullet is the whole point of the exercise. If a session can still reach a mutating tool by lifting its own client-side gate, nothing has been gained.
Proposed initial allowlist for opencode¶
Read-only to start, matching the credential posture already chosen for its GitHub access (read-only deploy keys):
kubectl_get_*,kubectl_describe,kubectl_get_logsprom_*(all read-only)discover_tools,select_tools,time_*- explicitly excluded: every
omada_*,ha_*mutation,kubectl_apply/patch/delete/scale/rollout, allarr_*
Widening the list later is a one-line policy change; starting wide and narrowing after an incident is not.
Open questions¶
- Why is Kuadrant disabled? Blocks everything else.
- Does Authorino coexist cleanly with Envoy Gateway's
SecurityPolicyextAuth? The cluster already runs Authelia extAuth on the Envoy gateways; Kuadrant/Authorino would run on the Istio gateway inmcp-system. They should not interact, but this needs confirming. - Do the other in-cluster clients eventually get identities too?
The end state is every MCP client authenticated. This proposal only
moves opencode, leaving the
mcplistener unauthenticated. - Token lifetime vs. long agent sessions. The rotator's 7-day lifespan with daily refresh suits shell sessions; a long-running server may need refresh-on-401 handling in the client.
What this does not solve¶
Tool-level authz bounds what an agent can do through MCP. It does
not bound the bash tool, which can reach anything the pod's egress
policy allows. The CNP remains the outer boundary; this proposal
tightens the inner one.