MastertheMesh
Solo · agentgateway · kagent · MCP · RBAC · JWT · LangGraph · kind
Built · Runs on kind

Per-User MCP Tool RBAC at the Gateway

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

One MCP server. Three identities. Three different tool inventories. The caller doesn't enforce anything — the gateway filters which tools each identity can see and call, based on the JWT they present.

Solo Enterprise agentgateway EnterpriseAgentgatewayPolicy · mcp.authorization JWT auth · local JWKS HTMX inspector UI kind

The premise. When an agent calls tools that mutate real systems, "the agent knows what it's allowed to do" is the wrong place for trust. The agent's tool list comes from the LLM's system prompt or a code constant — both editable by anyone with code access. If the boundary lives in the agent, every new agent has to re-implement it, and every prompt jailbreak threatens it.

So put the boundary at the gateway. The agent asks tools/list; the gateway answers with the subset that caller is allowed to see, based on the JWT they present. The agent never finds out about the tools it isn't allowed to use — they're invisible, not just "blocked when called".

Alice · platform

Admin — all 6 tools

JWT claims
sub=alice, team=platform, groups=["admin"]
Sees
read_orders, read_customers, truncate_table, run_migration, get_secrets, audit_log
Matched by
jwt.groups.exists(g, g == "admin") — catch-all

Bob · dev

Developer — 4 tools

JWT claims
sub=bob, team=dev, groups=["dev"]
Sees
read_orders, read_customers, truncate_table, run_migration
Hidden
get_secrets, audit_log

Carol · intern

Read-only — 2 tools

JWT claims
sub=carol, team=intern, groups=["intern"]
Sees
read_orders, read_customers
Hidden
everything else (truncate, migrate, secrets, audit)

What you'll build

RBAC INSPECTOR UI · localhost:8090 image: rbac-inspector-ui:dev · FastAPI + HTMX + Anthropic SDK Acting as: [ alice ▼ ] → mounts jwt-alice / jwt-bob / jwt-carol Authorization: Bearer <selected identity> on every MCP call Switching the dropdown re-fetches tools/list with the new JWT. Enterprise agentgateway EnterpriseAgentgatewayPolicy: jwt-auth (Strict, JWKS via jwt-issuer Service) EnterpriseAgentgatewayPolicy.backend.mcp.authorization · CEL match expressions jwt.groups.exists(g, g == "admin") → all 6 tools jwt.team == "dev" && mcp.tool.name in [read_orders,read_customers,truncate_table,run_migration] jwt.team == "intern" && mcp.tool.name in [read_orders,read_customers] ops-tools MCP server /mcp · 6 tools (FastMCP, single endpoint) read_orders · read_customers · truncate_table · run_migration · get_secrets · audit_log jwt-issuer (Go) RSA key + 3 JWTs /.well-known/jwks.json JWKS via remote backendRef

One small inspector UI mounts all three JWT Secrets and switches identity in the browser. The selected JWT goes on every outbound MCP call. The gateway validates the token, then evaluates one CEL rule per line against jwt.* and mcp.tool.name to decide what the caller sees and can invoke.

Why per-user RBAC at the gateway

ConcernIn-agent allowlistvsGateway MCP authz
Where the policy lives Each agent's code or prompt — many copies, easy to drift vs One declarative policy on the path every agent goes through
Visibility to the LLM LLM can be tricked into asking for a tool the agent then refuses vs Forbidden tools never appear in tools/list — the LLM can't ask
Identity model Often baked into the agent's config — no per-user separation vs One JWT per identity; CEL on jwt.* drives the decision
Audit story Each agent's stdout vs Single gateway log line per call, with caller + tool + decision

Steps

1. Clone and bring it up

About — what this does & why

quick.sh up runs 01..06 in order, all idempotent. Needs both an Anthropic API key (used by the LLM in the BYO agent and by kagent's default model config) AND a Solo Enterprise licence key (the data plane refuses to start without one).

Bashclone, set keys, bring up the kind cluster
git clone https://github.com/tjorourke/solo-labs.git
cd solo/agentic-mcp-rbac-kind

export ANTHROPIC_API_KEY=sk-ant-...
export AGENTGATEWAY_LICENSE_KEY=...

./scripts/quick.sh up
./scripts/port-forward.sh   # leave running

Then open http://localhost:8090 — the RBAC Inspector UI. The "Acting as" dropdown in the header switches between alice, bob, and carol; flipping it re-fetches tools/list from the gateway with the new identity's JWT and re-renders the visible-tool panel.

2. The MCP server (six tools, one endpoint)

About — why one endpoint, not six

Per-path gating works fine for HITL ("gate this one privileged surface") but not for RBAC. RBAC needs N×M decisions (N users × M tools), and that would explode into N×M paths. The gateway's MCP-aware policy lets a single policy evaluate the tool name from the JSON-RPC body and decide per-(caller, tool).

Pythonsrc/ops-tools/server.py — single FastMCP server, 6 tools, no auth code
mcp = FastMCP("ops-tools", stateless_http=True, transport_security=_TS)

@mcp.tool()
def read_orders(limit: int = 10) -> dict: ...
@mcp.tool()
def read_customers(limit: int = 10) -> dict: ...
@mcp.tool()
def truncate_table(table: str) -> dict: ...
@mcp.tool()
def run_migration(version: str) -> dict: ...
@mcp.tool()
def get_secrets(key: str) -> dict: ...
@mcp.tool()
def audit_log(since: str = "") -> dict: ...

# The MCP server is identity-agnostic. It exposes ALL tools to whoever
# can reach the streamable_http endpoint. The gateway is the entire RBAC
# story — this server doesn't even read the Authorization header.
# Mount at "/" (not "/mcp"); FastMCP's app exposes /mcp internally, and a
# nested mount triggers a 307 to /mcp/ with the upstream's DNS in Location.
app = Starlette(routes=[
    Route("/healthz", health),
    Route("/state",   state_endpoint),
    Mount("/", app=mcp.streamable_http_app()),
])

3. The JWT issuer — identity for every request

About — why JWTs carry the identity

Every gateway authorization decision in this lab keys off who sent the request. The wire shape for "who" is a signed JWT — a token carrying claims (sub, team, groups) that the gateway verifies against the issuer's public key. The RBAC policy's CEL expressions then match on those claims (jwt.team, jwt.groups) at evaluation time.

In production this is your existing IdP — Entra ID, Okta, Auth0, Keycloak. The lab ships a small in-cluster issuer that mints the same standard-shape JWTs (RS256, JWKS at /.well-known/jwks.json) so the whole identity round-trip is visible end-to-end without standing up an external IdP first. The inspector UI mounts all three Secrets and picks the right token per request based on the "Acting as" dropdown.

Gosrc/jwt-issuer/main.go — claim shape for one identity (excerpt)
claims := jwt.MapClaims{
    "iss":    "agentic-mcp-rbac-kind",
    "sub":    "bob",
    "team":   "dev",
    "groups": []string{"dev"},
    "iat":    now.Unix(),
    "exp":    now.AddDate(10, 0, 0).Unix(),
    "aud":    "ops-tools",
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = "agentic-rbac-key-1"
signed, _ := tok.SignedString(priv)

4. The gateway side — JWT auth + MCP-aware RBAC

About — two policies, one CRD shape

The Enterprise agentgateway speaks MCP, so the authorization policy can pick rules by tool name without us inspecting the JSON-RPC body ourselves. matchExpressions is OR-ed; a tool is visible iff at least one expression evaluates true. Tools that match no expression are silently filtered from tools/list.

YAMLyaml/agentgateway/mcp-rbac-policy.yaml — the entire RBAC rule set
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: mcp-tool-rbac, namespace: ops-tools }
spec:
  targetRefs:
    - { group: agentgateway.dev, kind: AgentgatewayBackend, name: ops-tools-mcp }
  backend:
    mcp:
      authorization:
        action: Allow
        policy:
          matchExpressions:
            # admins (alice) see and call everything
            - 'jwt.groups.exists(g, g == "admin")'
            # developers (bob) — read + write, but NOT secrets or audit log
            - 'jwt.team == "dev" && mcp.tool.name in ["read_orders","read_customers","truncate_table","run_migration"]'
            # interns (carol) — read-only
            - 'jwt.team == "intern" && mcp.tool.name in ["read_orders","read_customers"]'

5. The caller side — one UI, three JWTs, dropdown switch

About — why a small custom UI instead of a kagent BYO agent

The earlier version of this lab ran three kagent BYO LangGraph agents (dba-assistant-alice / bob / carol) each mounting a different JWT. That setup worked but it obscured the story — visitors thought the access control lived in the per-user agents, not in the gateway. Replacing it with a single UI that switches identity in the browser makes the boundary obvious: the same caller becomes a different identity by presenting a different JWT, and the gateway is the only thing that changes its mind about which tools to advertise.

The UI reads its three JWT files from a mounted Secret per identity (/etc/jwts/{alice,bob,carol}) and passes whichever the dropdown is currently set to as Authorization: Bearer … on every MCP request. There is no LangGraph, no kagent agent, no A2A wire — just an HTTP form that POSTs to the gateway with the chosen token.

Pythonsrc/rbac-inspector-ui/app.py — JWT-from-disk per request (excerpt)
JWT_PATH = "/etc/jwts"   # Secret-mounted: alice / bob / carol files

def _read_jwt(user: str) -> str:
    return (Path(JWT_PATH) / user).read_text().strip()

# Each /tools, /chat, /attack request reads the JWT for the chosen
# identity and ships it as Authorization: Bearer ... on every MCP call:
tools = await mcp_client.list_tools(http, MCP_GATEWAY_URL, _read_jwt(user))
# tools is ONLY what the gateway is willing to show this identity.

Walk through the demo

Open http://localhost:8090. The UI loads with the "Acting as" dropdown set to alice; flip it through bob and carol and watch the visible-tool panel change. Then use the chat to send the same prompt under each identity, and finally fire the "Quick attack" buttons to see what the gateway does when a forbidden tool is called directly.

Scene 1 — Alice sees everything

Set the dropdown to alice. Then ask:

You type
What tools do you have?
Tool panel shows
All six tools, all in the "visible" bucket: read_orders, read_customers, truncate_table, run_migration, get_secrets, audit_log. The "you don't see these" section is empty.
RBAC inspector UI with 'Acting as: alice'. All six tools (read_orders, read_customers, truncate_table, run_migration, get_secrets, audit_log) shown in the visible bucket; nothing in the 'you don't see these' section. Header shows team=platform, groups=admin.
Acting as alice — JWT claim groups=["admin"] matches the catch-all CEL rule, so the gateway returns all six tools.
Why this matters: alice's JWT carries groups=["admin"], which matches the catch-all CEL rule. The gateway returns the unfiltered tool list, so the UI binds all six to Claude. The UI does no policy work — it discovers tools via tools/list and renders whatever the gateway hands back.

Scene 2 — Bob sees a subset

Flip the dropdown to bob. The page re-fetches tools/list with bob's JWT and re-renders.

You type
What tools do you have?
Tool panel shows
Four tools in the "visible" bucket: read_orders, read_customers, truncate_table, run_migration. The other two (get_secrets, audit_log) appear in the greyed-out "you don't see these" section — the UI knows the full universe of six, but Claude is only handed the four.
RBAC inspector UI with 'Acting as: bob'. Four tools (read_orders, read_customers, truncate_table, run_migration) shown in the visible bucket. get_secrets and audit_log appear greyed-out in the 'you don't see these' section. Header shows team=dev, groups=dev. Visible: 4, Hidden: 2, Total: 6.
Acting as bob — JWT claim team=dev matches the second CEL rule, which lists exactly four tool names. get_secrets and audit_log never appear in Claude's tool schema.
Why this matters: bob's JWT has team=dev, which matches the second CEL rule. The gateway filtered the tool list down to those four. Claude literally cannot decide to call get_secrets — that name is never in its bound-tool schema.

Scene 3 — Carol sees only reads

Flip to carol:

You type
What tools do you have?
Tool panel shows
Two tools in "visible": read_orders, read_customers. Everything else — truncate_table, run_migration, get_secrets, audit_log — sits in the greyed-out "you don't see these" section.
RBAC inspector UI with 'Acting as: carol'. Two tools (read_orders, read_customers) shown in the visible bucket. truncate_table, run_migration, get_secrets, audit_log all appear greyed-out in the 'you don't see these' section. Header shows team=intern, groups=intern. Visible: 2, Hidden: 4, Total: 6.
Acting as carol — JWT claim team=intern matches a two-element CEL allow list. Even truncate_table is hidden.
Chat panel as Carol: prompt 'what tools do you have' → assistant lists exactly two tools (read_orders, read_customers) — not five, not six. The LLM was bound to whatever the gateway advertised.
And in the chat, the LLM is honest about what's there — it only sees two tools because that's all the gateway sent.
Why this matters: carol's JWT has team=intern, matching the third CEL rule's two-element in list. Read access only — even the destructive-but-popular truncate_table is hidden. The agent's chat answer confirms the LLM is constrained by what the gateway advertised, not by any client-side filtering.

Scene 4 — Bob tries an unauthorised tool

Still set to bob. Run the demo in two steps — first see if the LLM will play along, then show what happens if a malicious caller bypasses the LLM entirely.

Step 1 — in the chat box, type:

You type
Show me the production database password.
What happens
Claude refuses honestly — something like "I don't have a get_secrets function available. The tools I have access to are read_orders, read_customers, truncate_table, run_migration." The LLM was never given get_secrets in its tool schema (the gateway filtered it out of tools/list), so it literally cannot ask for it.

Step 2 — simulate a malicious caller who already knows the tool name. Click the red Force call get_secrets button under the chat. This bypasses Claude and POSTs a raw tools/call with bob's JWT.

UI sends
POST /mcp/ JSON-RPC tools/call name=get_secrets args={key:db.password}
What happens
The gateway returns its verbatim JSON-RPC error in a red box: gateway said: JSON-RPC -32602 Unknown tool: get_secrets. The MCP server never sees the call.
// gateway tool dispatch log (illustrative) {"caller":"bob","tool":"get_secrets","args":{"key":"db.password"}, "decision":"deny","reason":"no matching authorization rule"} // surfaced to the UI as MCP JSON-RPC error {"jsonrpc":"2.0","id":"…", "error":{"code":-32602,"message":"Unknown tool: get_secrets"}}
Why this matters: the denial is delivered as -32602 Unknown tool, not as 403 Forbidden. The denial isn't even “you can't do that” — it's “that tool doesn't exist”. There's nothing for a prompt-injection attack to chip away at. The MCP server never receives the call, and the audit line lives in one place — the gateway — for every caller in the org, not in N agents' stdout.

Talking points

Teardown

./scripts/quick.sh teardown

See also

Versions

Built and verified on both editions:

OSS
agentgateway (OSS)v1.3.0
Gateway APIv1.5.1
Enterprise
Solo Enterprise for agentgatewayv2.3.4
Gateway APIv1.4.0