Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions development/examples/agentgateway/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.run/
52 changes: 52 additions & 0 deletions development/examples/agentgateway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# agentgateway in front of the mesh

Everything the guide at `site/content/docs/guides/agentgateway.md` walks
through, as one `docker compose` project and a script that checks each step:

- Keycloak 26.4.0 with the realm `mesh` (client `agentmesh`, an audience
mapper so access tokens carry `aud: agentmesh`, `alice` in group `devs`,
`bob` in group `ops`) and a second realm, `elsewhere`;
- agentgateway v1.6.0 ([`agentgateway.yaml`](agentgateway.yaml)), which
validates each caller's access token and exchanges it at a shared
`agentmesh-node` for a mesh credential of that caller;
- `agentmesh-control-plane` with the realm as its issuer, one router, the
calculator of [`../calc-mcp`](../calc-mcp) published as `mcp://calc`, and a
header echo (`echo.py`) behind the same agentgateway policies.

```sh
./run.sh # bring it up, run every case, exit non-zero on a mismatch
./run.sh down # remove the containers and the generated tokens
```

`run.sh` prints one line per case:

```
== Calling mcp://calc through agentgateway
PASS alice (calc-user through group:devs) 200
PASS bob (no calc-user), refused by the provider 403

== Refused at agentgateway
PASS no token 401
PASS not a token 401
PASS alice, token for client other-app (audience) 401
PASS alice, token from realm elsewhere (issuer) 401

== What a backend behind agentgateway receives
PASS the exchanged credential in x-mesh-authentication present
PASS the caller's token in authorization absent

== A token through its expiry (60s lifetime in this realm)
PASS alice, before exp 200
PASS alice, 20s after exp: accepted by agentgateway, refused at the exchange 400
PASS alice, 80s after exp: refused by agentgateway 401
PASS alice, a new token 200
```

The agentmesh images are pinned to the commit the example was tested with.
`AGENTMESH_TAG` selects another published commit or release, and
`AGENTMESH_REGISTRY` another registry.

`.run/` holds the admin, API and bootstrap tokens `run.sh` generates on each
run, and is not committed. The users' passwords in the realm files are for
this example only. Keycloak runs `start-dev` over plain `http` inside the
Compose network; the guide lists what to change for a real deployment.
53 changes: 53 additions & 0 deletions development/examples/agentgateway/agentgateway.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# agentgateway in front of a shared agentmesh-node.
#
# jwtAuth validates each caller's Keycloak access token and removes it from
# the request. The token exchange reads the validated token instead, trades
# it at the node's /oauth/token (RFC 8693) for a credential of that caller,
# and sends that credential to the backend in X-Mesh-Authentication. The
# node needs no client authentication for a JWT subject token, so the
# gateway holds no secret and no credential of its own.
#
# /mesh/ goes to the node's mesh proxy. /echo/ applies the same policies in
# front of a server that answers with the headers it received, to show what
# a backend gets.
config: {}
binds:
- port: 3000
listeners:
- name: default
protocol: HTTP
routes:
- name: mesh
matches:
- path:
pathPrefix: /mesh/
policies: &caller-policies
jwtAuth:
mode: strict
issuer: http://keycloak:8080/realms/mesh
audiences: [agentmesh]
jwks:
url: http://keycloak:8080/realms/mesh/protocol/openid-connect/certs
backends:
- host: gateway:8080
policies: &exchange
backendAuth:
oauthTokenExchange:
host: gateway:8080
path: /oauth/token
subjectToken:
tokenType: urn:ietf:params:oauth:token-type:jwt
source:
expression: jwt.rawToken.unredacted()
authorizationLocation:
header:
name: x-mesh-authentication
prefix: "Bearer "
- name: echo
matches:
- path:
pathPrefix: /echo/
policies: *caller-policies
backends:
- host: echo:8080
policies: *exchange
127 changes: 127 additions & 0 deletions development/examples/agentgateway/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# agentgateway in front of an Agent Mesh: two users in two Keycloak groups
# call one MCP service through agentgateway, which exchanges each caller's
# token for a mesh credential of that caller at a shared agentmesh-node.
# run.sh brings this up in order and runs the cases; see README.md.
#
# The agentmesh images are pinned to the commit the example was tested
# with. AGENTMESH_TAG selects another published commit or release, and
# AGENTMESH_REGISTRY another registry (for images built from this checkout).
name: agentmesh-agentgateway

x-agentmesh-node: &agentmesh-node
image: ${AGENTMESH_REGISTRY:-ghcr.io/google}/agentmesh-node:${AGENTMESH_TAG:-8de8bd76ddfa5c52957c02722b6bce2eac21ec72}
depends_on:
- router
volumes:
- ./.run:/run/example:ro
- ./node-provider.yaml:/etc/agentmesh/provider.yaml:ro

services:
keycloak:
image: quay.io/keycloak/keycloak:26.4.0
# start-dev serves plain http and keeps its data in the container: for
# this example only. Production Keycloak needs TLS, a database and a
# fixed hostname (https://www.keycloak.org/server/configuration-production).
command: ["start-dev", "--import-realm"]
environment:
KC_HEALTH_ENABLED: "true"
volumes:
- ./realm-mesh.json:/opt/keycloak/data/import/realm-mesh.json:ro
- ./realm-elsewhere.json:/opt/keycloak/data/import/realm-elsewhere.json:ro
healthcheck:
# The image has no HTTP client: ask the management port with bash and
# read the status line, so a 503 while the realms import is a failure.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && read -r status <&3 && [[ $$status == *' 200 '* ]]"]
interval: 5s
timeout: 5s
retries: 30
start_period: 20s

control-plane:
image: ${AGENTMESH_REGISTRY:-ghcr.io/google}/agentmesh-control-plane:${AGENTMESH_TAG:-8de8bd76ddfa5c52957c02722b6bce2eac21ec72}
command:
- --issuer=http://keycloak:8080/realms/mesh
- --allowed-audiences=agentmesh
- --admin-token-path=/run/example/admin.token
- --db-dsn=/data/control-plane.db
# The router and the two nodes enroll with single-use bootstrap tokens
# run.sh mints with the admin token; approving them as they arrive
# spares the script an approval step.
- --auto-approve-enrollment
volumes:
- ./.run:/run/example:ro
depends_on:
keycloak:
condition: service_healthy

router:
image: ${AGENTMESH_REGISTRY:-ghcr.io/google}/agentmesh-router:${AGENTMESH_TAG:-8de8bd76ddfa5c52957c02722b6bce2eac21ec72}
command:
- --control-plane=http://control-plane:8080
- --insecure-control-plane
- --bootstrap-token-path=/run/example/router.token
- --keys-path=/data/router.key
- --control-plane-sync-interval=10s
volumes:
- ./.run:/run/example:ro
depends_on:
- control-plane

calc:
build: ../calc-mcp
image: agentmesh-agentgateway/calc-mcp:local

# Publishes mcp://calc (node-provider.yaml).
provider:
<<: *agentmesh-node
command:
- run
- --control-plane=http://control-plane:8080
- --insecure-control-plane
- --bootstrap-token-path=/run/example/provider.token
- --config=/etc/agentmesh/provider.yaml
- --data-dir=/data
- --api-token-path=/run/example/provider-api.token
- --discovery-interval=1s
- --control-plane-sync-interval=10s

# The node agentgateway exchanges tokens at and sends requests to. Its own
# credential grants no service.
gateway:
<<: *agentmesh-node
command:
- run
- --control-plane=http://control-plane:8080
- --insecure-control-plane
- --bootstrap-token-path=/run/example/gateway.token
- --data-dir=/data
- --bind-addr=0.0.0.0:8080
- --api-token-path=/run/example/gateway-api.token
- --control-plane-sync-interval=10s

agentgateway:
image: ghcr.io/agentgateway/agentgateway:v1.6.0
command: ["-f", "/etc/agentgateway/config.yaml"]
volumes:
- ./agentgateway.yaml:/etc/agentgateway/config.yaml:ro
depends_on:
# agentgateway fetches the realm's keys when it starts.
keycloak:
condition: service_healthy

# Answers with the request headers it received (echo.py), behind the same
# agentgateway policies as the mesh route.
echo:
# The same image, by digest, as the repository's Python Dockerfiles.
image: python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
command: ["python3", "/echo.py"]
volumes:
- ./echo.py:/echo.py:ro

# The HTTP client run.sh uses inside the network, so token issuers and
# service names resolve exactly as the components see them.
tools:
image: curlimages/curl:8.11.1
profiles: ["cases"]
volumes:
- ./:/example:ro
21 changes: 21 additions & 0 deletions development/examples/agentgateway/echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Answers every POST with the request headers it received, values shortened,
# so run.sh can show what agentgateway forwards to a backend.
import http.server
import json


class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers.get("content-length", 0)))
headers = {k.lower(): v if len(v) <= 24 else v[:24] + "..." for k, v in self.headers.items()}
body = json.dumps(headers, sort_keys=True).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.end_headers()
self.wfile.write(body)

def log_message(self, *args):
pass


http.server.HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
9 changes: 9 additions & 0 deletions development/examples/agentgateway/node-provider.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The node that publishes mcp://calc. Its credential comes from a bootstrap
# token, and the mesh policy grants mesh:role:node allowed_publish mcp://*.
version: "v1alpha1"

services:
- type: mcp
name: calc
description: "add and multiply"
target_url: "http://calc:7777/mcp"
9 changes: 9 additions & 0 deletions development/examples/agentgateway/policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"roles": [
{ "name": "mesh:role:node", "allowed_targets": ["*"], "allowed_publish": ["mcp://*"] },
{ "name": "calc-user", "allowed_services": ["mcp://calc"], "allowed_targets": ["*"] }
],
"bindings": [
{ "role": "calc-user", "members": ["group:devs"] }
]
}
29 changes: 29 additions & 0 deletions development/examples/agentgateway/realm-elsewhere.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"realm": "elsewhere",
"enabled": true,
"accessTokenLifespan": 60,
"clients": [
{
"clientId": "agentmesh",
"publicClient": true,
"directAccessGrantsEnabled": true
}
],
"users": [
{
"username": "alice",
"enabled": true,
"email": "alice@example.test",
"emailVerified": true,
"firstName": "Alice",
"lastName": "Elsewhere",
"credentials": [
{
"type": "password",
"value": "alice-test-only",
"temporary": false
}
]
}
]
}
Loading
Loading