MastertheAgent
Solo · kagent · agentgateway · MCP tool authz · agent identity · Keycloak · enterprise · kind
Built · differential tool access verified live on kind

Agent identity and scoped tools: one agent can act, the other can't

Part 1 proved the shape two agents share. A diagnosis, though, is only a recommendation until someone acts on it, and acting needs privilege. Here two agents look at the same broken database. One can only diagnose it; the other has the identity to fix it. The difference is not in the agents' code or prompts, it is enforced at the gateway.

Who is allowed to act

A read-only dba-diagnoser and a privileged sre-remediator both reach one MCP server that stands in for the orders database. The enterprise agentgateway in front of it validates each agent's identity token and applies a per-tool authorization policy, so the same server exposes different tools to each agent. The privileged db_reset_credentials tool is simply invisible to the diagnoser.

dba-diagnoser identity: db-reader sre-remediator identity: db-operator Keycloak issues the identity tokens enterprise agentgateway 1 · validate JWT 2 · per-tool authz (jwt.groups + mcp.tool.name) filters tools/list, refuses tools/call mock-db (MCP) simulated Postgres db_status · list_tables db_query · db_reset_credentials Bearer (db-reader) Bearer (db-operator) MCP The read-only agent gets db_status / list_tables / db_query. The privileged agent additionally gets db_reset_credentials. Same MCP server; the gateway is the authority.

Do you need a mesh for this?

No. The authorization happens at the agentgateway that fronts the MCP server, so there is no Istio or ambient waypoint here. (kagent's AccessPolicy can also gate MCP tools, but that path enforces through an ambient waypoint and needs the mesh — a heavier setup for the same outcome.) This lab keeps it to the gateway. For the full decision — when to reach for EnterpriseAgentgatewayPolicy versus AccessPolicy, and when to layer both — see the reference: which authorization layer, when.

The mechanism: per-tool authorization at the gateway

Two EnterpriseAgentgatewayPolicy resources. The first authenticates every request against Keycloak. The second attaches to the MCP backend and, for each tool call, evaluates CEL over the token's groups claim and the tool name. Rules are OR-ed; a tool a caller matches nothing for is filtered from tools/list and refused on tools/call.

yamlyaml/agentgateway/mcp-authz-policy.yaml
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: mcp-tool-authz
  namespace: mock-db
spec:
  targetRefs:
    - group: agentgateway.dev
      kind: AgentgatewayBackend
      name: mock-db-mcp
  backend:
    mcp:
      authorization:
        action: Allow
        policy:
          matchExpressions:
            # operators can call every tool (no tool-name restriction)
            - 'has(jwt.groups) && "db-operator" in jwt.groups'
            # readers get the read-only tools only
            - 'has(jwt.groups) && "db-reader" in jwt.groups &&
               mcp.tool.name in ["db_status","list_tables","db_query"]'
yamlyaml/agentgateway/jwt-policy.yaml
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: jwt-auth
  namespace: agentgateway-system
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: db-gateway
  traffic:
    jwtAuthentication:
      mode: Strict
      providers:
        - issuer: "http://keycloak.keycloak.svc.cluster.local/realms/solo"
          jwks:
            remote:
              jwksPath: "/realms/solo/protocol/openid-connect/certs"
              cacheDuration: "5m"
              backendRef:
                group: ""
                kind: Service
                name: keycloak
                namespace: keycloak
                port: 80
yamlyaml/agentgateway/backend-route.yaml (the MCP backend)
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: mock-db-mcp
  namespace: mock-db
spec:
  mcp:
    targets:
      - name: mock-db
        static:
          host: mock-db.mock-db.svc.cluster.local
          port: 8080
          path: /mcp
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: mock-db-route
  namespace: mock-db
spec:
  parentRefs:
    - { name: db-gateway, namespace: agentgateway-system }
  rules:
    - matches: [{ path: { type: PathPrefix, value: /mcp } }]
      backendRefs:
        - { group: agentgateway.dev, kind: AgentgatewayBackend, name: mock-db-mcp }

Giving each agent an identity

The identity a policy keys on is a token claim. Each agent injects its own Keycloak token on every MCP call through its RemoteMCPServer's headersFrom. Two identities in the realm — agent-diagnoser in group db-reader, agent-remediator in group db-operator — mean two tokens, and so two tool sets from one server.

yamlyaml/agents/remotemcpservers.yaml
apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata:
  name: db-mcp-reader
  namespace: kagent
spec:
  description: Mock orders database, reached with a read-only (db-reader) identity.
  url: http://db-gateway.agentgateway-system.svc.cluster.local:80/mcp
  protocol: STREAMABLE_HTTP
  headersFrom:
    - name: Authorization
      valueFrom: { type: Secret, name: agent-token-reader, key: authorization }
---
apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata:
  name: db-mcp-operator
  namespace: kagent
spec:
  description: Mock orders database, reached with a privileged (db-operator) identity.
  url: http://db-gateway.agentgateway-system.svc.cluster.local:80/mcp
  protocol: STREAMABLE_HTTP
  headersFrom:
    - name: Authorization
      valueFrom: { type: Secret, name: agent-token-operator, key: authorization }
yamlyaml/agents/dba-diagnoser.yaml + sre-remediator.yaml
# read-only diagnoser — references the db-reader RemoteMCPServer
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata: { name: dba-diagnoser, namespace: kagent }
spec:
  type: Declarative
  declarative:
    modelConfig: default-model-config
    systemMessage: |
      You are a read-only database specialist for "orders". Inspect it, work out
      the root cause, and report root_cause, severity, fix, runbook_url. You do NOT
      have permission to change the database; name the privileged action the
      operator must take rather than attempting it.
    tools:
      - type: McpServer
        mcpServer: { apiGroup: kagent.dev, kind: RemoteMCPServer, name: db-mcp-reader,
                     toolNames: [db_status, list_tables, db_query] }
---
# privileged remediator — references the db-operator RemoteMCPServer
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata: { name: sre-remediator, namespace: kagent }
spec:
  type: Declarative
  declarative:
    modelConfig: default-model-config
    systemMessage: |
      You are the privileged on-call operator for "orders". If it is locked because
      the superuser password is not set, FIX it by calling db_reset_credentials with
      a strong password, then confirm it is healthy.
    tools:
      - type: McpServer
        mcpServer: { apiGroup: kagent.dev, kind: RemoteMCPServer, name: db-mcp-operator,
                     toolNames: [db_status, list_tables, db_query, db_reset_credentials] }

The mock database

No real Postgres is deployed. images/mock-db is a small MCP server that simulates the locked orders database: read tools plus one privileged write, db_reset_credentials, that unlocks it. It never checks who is calling — the gateway is the sole authority.

pythonimages/mock-db/server.py (tools)
@mcp.tool()
def db_status() -> dict:
    """Report the orders database health: reachable, whether login works, status."""
    ...

@mcp.tool()
def list_tables() -> dict: ...
@mcp.tool()
def db_query(table: str, limit: int = 10) -> dict: ...

@mcp.tool()
def db_reset_credentials(new_password: str) -> dict:
    """Set the orders superuser password and unlock the database (privileged)."""
    db.locked = False
    return {"database": db.name, "status": "healthy",
            "message": "superuser password set; database unlocked"}

Build it

Separate enterprise kind cluster. Needs an Anthropic key and the two Solo licenses.

export ANTHROPIC_API_KEY=sk-ant-...
export SOLO_LICENSE_KEY=...            # Solo Enterprise for kagent
export AGENTGATEWAY_LICENSE_KEY=...    # enterprise agentgateway
./scripts/quick.sh up

See the split

The headline: tools/list through the gateway, once per identity.

./scripts/tools.sh

Captured live from the cluster — same endpoint, two identities:

db-reader · dba-diagnoser

3 tools

"http_status": 200,
"tools": [
  "db_status",
  "list_tables",
  "db_query"
]

db-operator · sre-remediator

4 tools

"http_status": 200,
"tools": [
  "db_status",
  "list_tables",
  "db_query",
  "db_reset_credentials"   <- operator only
]

The gateway filtered db_reset_credentials out of the reader's list. kagent sees this too — the controller logs the discovery as db-mcp-reader toolCount 3 and db-mcp-operator toolCount 4, so the read-only agent's model is never even offered the privileged tool.

One agent can, the other can't

Fire the privileged tool with each identity, straight at the gateway:

./scripts/prove.sh
outputprove.sh — verified live
Before — db_status (as operator): the orders DB is locked
  "login": "FATAL: password authentication failed for user \"orders\" ...",
  "status": "degraded"

db-reader (dba-diagnoser) tries db_reset_credentials -> REFUSED
  "http_status": 400,
  "error": { "code": -32602, "message": "Unknown tool: db_reset_credentials" }

db-operator (sre-remediator) calls db_reset_credentials -> SUCCESS
  "status": "healthy",
  "message": "superuser password set; database unlocked and accepting logins"

After — db_status (as operator): the orders DB is healthy
  "login": "ok", "password_set": true, "status": "healthy"

The reader's identity does not just get a 403 — the tool does not exist for it, so the call comes back as MCP -32602 Unknown tool. The operator's identity resolves the same tool and the database recovers. One tool call, two identities, two outcomes.

End to end

And through the agents themselves:

./scripts/ask.sh dba-diagnoser  "the orders database is down - diagnose it"
./scripts/ask.sh sre-remediator "the orders database is down - fix it"

In the verified run the diagnoser inspects the database, reports the root cause and the fix, and signs off: "I cannot perform this myself as it requires privileged database administrative access that I don't have" — its identity has no write tool to call. The remediator inspects the same database, calls db_reset_credentials, and reports it "back online and fully operational". Same incident, same prompt shape, different identity — and only one of them can act.

Why this is enterprise-only

The EnterpriseAgentgatewayPolicy backend.mcp.authorization (per-tool CEL) and jwtAuthentication have no OSS equivalent, and it runs on Solo Enterprise for kagent plus enterprise agentgateway. Part 1 is OSS — the shape — and this is the enterprise layer on top: identity and authorization.

Extending it

See also

Versions

Built and verified on:

Enterprise
Gateway APIv1.4.0
Keycloak26.3
Solo Enterprise for agentgatewayv2.3.4
Solo Enterprise for kagent0.4.3