Example: Keycloak and Kong in front of mesh7
A worked example, built and run end to end with a real agent: scout7, a research agent that searches the web, reads pages, analyses them with a local model, remembers what it found and draws architecture diagrams. Every tool call goes through mesh7.
Three layers, three questions:
| Layer | Answers | Sees |
|---|---|---|
| Keycloak | Who is acting, and what may they delegate? | Users, roles, clients, scopes |
| Kong (Konnect or Gateway Enterprise 3.14+) | May this agent, for this person, reach this tool? | The token and its scopes, the MCP method and tool name |
| mesh7 | Should this act, with these arguments, run, wait for a human, or stop? | The tool, its arguments, the agent and the human, the history |
Kong opens the door; mesh7 judges the act. Each layer is useful alone; the example adds them one at a time.
human ──sign-in──► Keycloak
│ token (human) ┌── exchange (RFC 8693) ──┐
chat ────────────────┴──────────────────────────┘ ▼
token: azp=scout7, preferred_username=bob,
scope=web:read memory:write …
scout7 ── MCP ──► Kong :8010 ── ai-mcp-oauth2 (token) ── ai-mcp-proxy (scope per tool) ──►
mesh7 :9196 ── policy (arguments, approval) ── trace ──► tools (search, fetch, LLM, memory, diagrams)
Part 1: Keycloak and mesh7
The two ways an agent gets a token
An agent acting for itself (a scheduled run, nobody behind it): a confidential client with a service account, client credentials grant.
An agent acting for a human: the human signs in on a front end (a chat, a portal) with the authorization code flow and PKCE, so the password never reaches the front end. The front end then exchanges the human's token for one meant for the agent (RFC 8693, Keycloak's standard token exchange, on by default since Keycloak 26.2). The exchanged token names both:
{ "azp": "scout7", "preferred_username": "bob", "aud": "mesh7",
"scope": "web:read memory:read memory:write diagram:write" }
The realm, in short
- Client scopes, one per capability:
web:read,memory:read,memory:write,diagram:write, with include in token scope on. - An audience mapper adding
mesh7to the tokens, since mesh7 checksaud. - Delegation by role: map the write scopes to a realm role (
scout-writer). Keycloak puts a scope in a token only when the person holds one of its mapped roles, so bob (who has the role) delegates writing, alice (who has not) delegates reading only. Give the role to the agent's service account too, so it keeps writing on its own. - The agent's client (
scout7): confidential, service account on, attributestandard.token.exchange.enabled: true. Its scopes as default client scopes (see pitfalls). A longer access token lifespan than the 5-minute default if a run outlasts it. - The front end's client: standard flow, PKCE
S256, its redirect and post-logout URIs, and an audience mapper adding the agent's client id: the agent may exchange only a token meant for it.
The exchange, from the front end's server side:
curl -s $KC/realms/agents/protocol/openid-connect/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d client_id=scout7 -d client_secret=$SCOUT7_SECRET \
-d subject_token=$HUMAN_ACCESS_TOKEN \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token
mesh7
auth:
jwt:
jwks_url: http://localhost:8180/realms/agents/protocol/openid-connect/certs
issuer: http://localhost:8180/realms/agents
audience: mesh7
agent_claim: azp # the agent is the client
user_claim: preferred_username # the human it acts for, on every trace
The trace then reads scout7 for bob, and the OTel span carries enduser.id. See JWT Authentication.
The policy judges what a scope cannot express: the arguments, and what needs a person.
name: scout7
agent: "scout7"
rules:
# Reading the web: https pages only, never an internal address.
- tools: ["fetch.fetch"]
action: allow
condition: { field: url, operator: starts_with, value: ["https://"] }
- tools: ["fetch.fetch"]
action: deny
- tools: ["searxng.searxng_web_search", "ollama.chat", "memory.memory_search", "memory.memory_list"]
action: allow
# A diagram is a deliverable: a human approves each one, and only in the
# demo directory; anywhere else is refused.
- tools: ["memory.memory_store"]
action: allow
- tools: ["arch7.create_diagram"]
action: human_approval
condition: { field: output_path, operator: starts_with, value: ["/data/diagrams/"] }
- tools: ["arch7.create_diagram"]
action: deny
- tools: ["*"]
action: deny
An agent that waits for the human retries the same call; mesh7 answers with the same pending approval, then runs the call once approved, or tells the agent once that it was refused. See Approval Flow.
Part 2: adding Kong
Kong's MCP plugins are Enterprise: Kong Gateway Enterprise 3.14+, or Kong Konnect, whose control plane is SaaS while the data plane node runs next to mesh7 (it dials out over mTLS, no inbound port).
# decK: deck gateway sync kong.yaml --konnect-token … --konnect-control-plane-name …
_format_version: "3.0"
services:
- name: mesh7-scout
url: http://127.0.0.1:9196/mcp # the mesh's MCP endpoint
routes:
- name: scout-mcp
expression: 'http.path == "/scout/mcp"' # Konnect nodes run the expressions router
protocols: [http]
strip_path: true # /scout/mcp -> the service's /mcp
plugins:
- name: ai-mcp-oauth2
config:
resource: http://localhost:8010/scout/mcp
authorization_servers: [http://localhost:8180/realms/agents]
jwks_endpoint: http://localhost:8180/realms/agents/protocol/openid-connect/certs
passthrough_credentials: true # mesh7 needs the token to name agent and human
insecure_relaxed_audience_validation: true # see pitfalls; mesh7 still checks aud
- name: ai-mcp-proxy
config:
mode: passthrough-listener
acl_attribute_type: oauth_access_token
access_token_claim_field: '.scope | split(" ")'
default_acl:
- scope: tools
allow: ["kong:unlisted-tool"] # a tool not listed below is refused
tools:
- { name: searxng.searxng_web_search, description: Search the web., acl: { allow: ["web:read"] } }
- { name: fetch.fetch, description: Fetch a page., acl: { allow: ["web:read"] } }
- { name: ollama.chat, description: Analyse with a model., acl: { allow: ["web:read"] } }
- { name: memory.memory_search, description: Search memory., acl: { allow: ["memory:read"] } }
- { name: memory.memory_list, description: List memories., acl: { allow: ["memory:read"] } }
- { name: memory.memory_store, description: Store a finding., acl: { allow: ["memory:write"] } }
- { name: arch7.create_diagram, description: Draw a diagram., acl: { allow: ["diagram:write"] } }
tools/list is filtered per scope: the agent acting for alice is not even offered the write tools. A tools/call to a tool outside the token's scopes is refused with a 403 before mesh7 sees it.
Part 3: one trace, from Kong to the tool
# Kong, on the service
- name: opentelemetry
config:
traces_endpoint: http://127.0.0.1:4318/v1/traces
propagation: { default_format: w3c }
Tracing is off by default on the node: set KONG_TRACING_INSTRUMENTATIONS=all (and a sampling rate). On mesh7, otel_endpoint: http://localhost:4318. Kong propagates a traceparent; mesh7 joins it, so one call is one trace: Kong's router and MCP plugin spans, then the mesh7 span for the tool, under Kong's balancer span. See Observability.
Who catches what
Played end to end with the stack above:
| Situation | Keycloak | Kong | mesh7 |
|---|---|---|---|
| No token | 401 | ||
| alice asks scout7 to store a memory or draw | issues a token without the write scopes | 403 | never sees it |
| A tool no one declared | 403 (default ACL) | refuses too (*: deny) |
|
| MCP header names one tool, body calls another | 403 (the plugin reads the body) | would judge the body | |
fetch to an http:// address (cloud metadata, an internal service) |
scope web:read present |
lets it through (200) | refused on the argument |
| bob's scout7 draws a diagram | scope present | lets it through | held for a human, approved or refused in the console |
| The upstream server changes a tool's description | held back (pin_tools) |
Kong decides on the identity and the tool name; mesh7 decides on the act.
Pitfalls met on the way
- Requesting a scope the client no longer has makes Keycloak refuse the whole token (
invalid_scope). For an agent whose scopes you may revoke, make them default client scopes and request none: revoking one removes it from the next token, and nothing else. resourcemust be a URL inai-mcp-oauth2. If your tokens carry another audience (heremesh7), either add the resource URL as an audience in Keycloak or relax Kong's audience check and let mesh7 enforceaud.- The
scopeclaim is one space-separated string, and the ACL compares whole values: withoutsplit(" "), every listed tool is hidden from everyone. "*"is not a wildcard indefault_acl: tools not listed would pass. Ask for a scope no token carries instead.- Service URL and route path add up: a service at
…/mcpbehind a route/mcpwithoutstrip_pathcalls/mcp/mcp. - The Konnect node image runs as uid 1001: pass the cluster certificate and key as environment values (as Konnect's own
docker rundoes) rather than as files private to your user. - Password grant is for tests only: the front end should use the authorization code flow with PKCE, and sign the user out of Keycloak too, or its session signs the same person straight back in.
Limits, today
- mesh7 records the human (
user_claim) but its policies do not yet decide on the human or on the organisation: alice and bob are told apart by Keycloak (roles) and Kong (scopes). Claim-based conditions are on the roadmap. - The
fetchrule compares the start of the URL: it stopshttp://addresses, not an internal service reached overhttps://. It is a text condition, not an SSRF guard; resolving the host and refusing private addresses would belong in the tool or in a dedicated check. - The agent must speak MCP Streamable HTTP to go through Kong's MCP plugins; mesh7's REST data plane (
POST /tool/…) needs a plain route and loses the per-tool ACL.