themsquared/agentgateway-standalone-demo
runs Solo Enterprise for agentgateway as a single docker compose up, no Kubernetes cluster and no cloud provider credentials. It walks three acts, each one a complete config.yaml the proxy reloads in place, and a --check flag that asserts every claim the demo makes instead of asking you to take its word for it. Two things in it are worth writing down on their own: a guardrail default that silently does not cover what you’d assume it covers, and an enterprise feature that turns a six-tool MCP catalog into one tool.
What’s actually running
The compose file brings up the agentgateway bundle image, two mock OpenAI-compatible upstreams (plus a third standing in for a self-hosted model), two small MCP servers, a Keycloak realm for OIDC, and an OTel collector. The only enterprise-specific requirement is a license key file, referenced from config.license.key.file; everything else, models, budgets, guardrails, and MCP targets, is ordinary YAML. Nothing calls a real cloud API and nothing requires a real identity provider; swap the provider/baseUrl pair in any act’s config and the same policies point at a live model instead. The project’s own line for this: “All data in this repo is invented. No real person, record or organisation is represented.”
The acts are additive, not a chain of separate demos:
config/acts/00-base.yaml gateway only: no identity, budgets, or guardrails
config/acts/01-developer.yaml + models, virtual models, failover, guardrails
config/acts/02-cost.yaml + virtual keys, allowlists, budgets, toolMode
config/acts/03-governance.yaml + tool authorization, UI OIDC, delegation
Act 0 is the starting configuration: traffic flows through one gateway, every MCP tool is visible to every caller, and nobody can answer who spent what. Each later act is Act 0 plus more policy in the same file, so the whole governance posture for any act is one YAML document you can read top to bottom, not a diff against something you have to hold in your head.
The guardrail gotcha: tool results aren’t scanned by default
Act 1’s guardrail looks like it should catch anything sensitive moving through the gateway:
guardrails:
request:
- scope: [systemPrompt, messages, toolOutput]
regex:
action: reject
rules:
- builtin: email
- builtin: phoneNumber
- builtin: creditCard
- pattern: '\bACCT-[0-9]{6}\b'
rejection:
status: 400
body: |
{"error":{"code":"data_policy","message":"Blocked at the AI gateway: this request carried customer-identifying data. Source code is fine to send; customer records are not."}}
The field that matters is scope, and it’s the one it’s easy to get wrong in the other direction. A request guardrail’s default scope covers the system prompt and chat messages only. Content a tool hands back to the model, a database row, a ticket body, a file’s contents, is not read at all unless toolOutput is in the list. And scope replaces the default rather than extending it, so a policy that names only toolOutput stops catching anything typed into a prompt. All three values have to be listed together, every time.
This matters more now than it did when the rule of thumb was “scan what the user types,” because coding agents stopped being pure completion tools. They’re agentic: a task pulls in whatever context it needs, which means a tool result, a file read, a kubectl get output, can carry exactly the data a prompt-only guardrail was built to catch. A guard that only reads the prompt misses the request that actually changed.
toolMode: code, one tool instead of six
Act 2 turns on toolMode: code on the MCP side:
mcp:
port: 3000
toolMode: code
codeModeSettings:
timeout: 1m0s
cpuTimeout: 1s
With toolMode: standard (the default, and the only mode the open source proxy ever runs), every MCP tool across every federated server shows up in tools/list and the model picks one call at a time. toolMode: code replaces that catalog with a single run_code tool: the model writes a short script that calls whatever tools it needs, chains the results, and only the composed output comes back. The demo’s version of this collapses a six-tool federated catalog spanning two MCP servers into one tool, with a script that calls both servers and returns a single combined answer instead of two raw tool responses the model would otherwise have to reconcile itself.
It’s an enterprise feature in the literal sense that the config key does nothing without a license: the open source proxy always behaves as toolMode: standard, silently, no error and no warning in the logs. --check is what catches the difference, since the demo asserts the tool count drops to one rather than just hoping the config took effect.
I wrote about per-key LLM spend budgets on agentgateway 1.5
when that feature shipped standalone; Act 2 here runs the same Audit-then-Block pattern on virtual keys issued per project rather than per person, so a platform team’s cost rollup matches how it actually reports spend. If you haven’t seen that budget mechanism, that post has the full walkthrough; the short version is that a budget is charged after the response returns, so it caps what a key can start rather than what it can spend mid-request.
MCP tool authorization: write allow rules, not deny rules
Act 3 adds mcpAuthorization:
mcpAuthorization:
rules:
- allow: 'mcp.tool.name in ["lookup_record","search_orders","get_service_health","get_deploy_status"]'
The schema’s own guidance is to prefer allow over deny, and the reason is the failure mode, not the style. A deny rule that fails to evaluate (a bad CEL expression, a field that doesn’t exist yet on some request) fails open: the request that should have been blocked goes through. An allow rule that fails to evaluate fails closed: anything not explicitly matched is refused. In a regulated environment, that’s the entire argument for one over the other.
The refusal is also quieter than a 403. A tool not covered by the allow rule is filtered out of tools/list before the response ever reaches the caller, so the agent doesn’t learn the tool exists, let alone that it was denied. If you’ve hit agentgateway’s CEL authorization gotchas on the HTTP side (matchExpressions that OR when you expected AND, an empty llm.requestModel at the wrong phase), that post
covers the policy-language half of this; mcpAuthorization is the same fail-open trap applied to which tools an agent can see at all.
The admin UI behind OIDC, and a token for two parties
The admin address in every act has no authentication of its own, and the cost dashboard lives there, so Act 3 puts a gateway in front of it instead of exposing it directly:
ui:
gateways: [ui-gateway]
policies:
oidc:
issuer: http://keycloak:18083/realms/demo
clientId: demo-ui
authorization:
rules:
- allow: '"ai-admins" in jwt.groups'
Keycloak stands in for any OIDC provider; swap the issuer and the authorization rule reads whatever claim your provider puts groups in. The same act turns on the bundled security token service and exchanges a token carrying sub (the human) and act (the agent acting for them), the RFC 8693 delegation shape, so a downstream service can audit the two separately instead of seeing one shared service-account identity. I’ve covered that token exchange in more depth in the standalone token service post
and from the MCP side in MCP Cross App Access
; this demo is the shortest path to seeing both the admin-UI OIDC gate and the delegated token in the same running stack.
Running it
git clone https://github.com/themsquared/agentgateway-standalone-demo && cd agentgateway-standalone-demo
./scripts/setup.sh # .env, licence key file, STS signing key
docker compose up -d
./scripts/setup-keycloak.sh # once, after Keycloak is up
./demo.sh --check # non-interactive: asserts everything
./demo.sh # interactive walkthrough, Enter to advance
--check is the one to trust. If it prints All assertions passed., the interactive walkthrough will work; if it doesn’t, nothing downstream is worth debugging yet. ./demo.sh --act 2 resets and fast-forwards silently to a specific act, and --reset restarts the proxy on Act 0’s config rather than swapping a file back, so repeated runs stay honest about what’s actually loaded.
I haven’t re-run this end to end myself for this post (it needs a Solo Enterprise license key, which isn’t something I attach to an unattended write-up), so treat the command sequence above and the config excerpts as read from the repo rather than independently verified here. The --check design is exactly built for that gap: run it yourself before trusting any of the acts past Act 0.
What’s actually new here
If you’ve read the budget post, the CEL gotchas post, or the token service post, most of the individual pieces in this demo aren’t new. What’s worth the clone is the packaging and the two gotchas this post leads with: a guardrail scope default that quietly stops covering tool output the moment you add anything else to the list, and toolMode: code, which is the one feature in the bundle I hadn’t written up before. Both are the kind of thing you only notice once you’ve already shipped the config that doesn’t do what you assumed.
Repo again: themsquared/agentgateway-standalone-demo
. Next on the list is pointing ./scripts/client-setup.sh at a real coding agent instead of the bundled mocks and seeing whether the guardrail catches a real tool result carrying something it shouldn’t.