# agentgateway Standalone: Guardrails, Budgets, Tool Governance

> A three-act agentgateway Enterprise demo in one docker-compose file: a guardrail gotcha on tool-result scanning, toolMode: code, and MCP tool authorization.

- Canonical URL: https://webofmike.com/agentgateway-standalone-demo/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-10-17
- Last modified: 2026-10-17
- Tags: AI Gateways, MCP, Security, Tutorials
- Cite as: Mike Moore, "agentgateway Standalone: Guardrails, Budgets, Tool Governance", Web of Mike (webofmike.com), 2026-10-17. https://webofmike.com/agentgateway-standalone-demo/


[themsquared/agentgateway-standalone-demo](https://github.com/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:

```yaml
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:

```yaml
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](/agentgateway-per-key-llm-budgets/) 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`:

```yaml
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](/agentgateway-cel-authorization-gotchas/) 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:

```yaml
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](/agent-token-service-standalone/) and from the MCP side in [MCP Cross App Access](/mcp-oauth-xaa/); 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

```bash
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](https://github.com/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.

