
Once an organisation has more than a couple of MCP servers, every agent ends up holding its own list of URLs and credentials, and nobody can answer "which agent can call which tool?" in one place. gcp-mcp-gateway-gke is a reference implementation of an MCP gateway: one controlled entry point between AI agents and the servers they use. It runs on GKE Autopilot with agentgateway 1.6, Keycloak as the identity provider, three MCP servers, and a Gemini-based agent, against the 2026-07-28 MCP specification revision.
What happens on one request
- The client calls the gateway with no token and gets
401, plus pointers to the discovery documents that say where to log in. This is how an MCP client that has never seen the server finds the identity provider. - The client logs in at Keycloak with the client-credentials grant and receives a signed JWT valid for 5 minutes, carrying its role (
mcp-viewerormcp-operator) and the audience (this gateway). - The gateway verifies signature, issuer, audience and expiry (failure: 401), requires a role (none: 403), and applies a per-tool rule. A viewer sees and may call only an explicit allow-list of read tools. A tool a role may not use is absent from its
tools/list, and calling it directly answers "Unknown tool". - The gateway calls the target server as itself, presenting a Google-signed ID token whose audience is that one server. The caller's token is never forwarded.
- The server verifies that credential and reaches its data service with its own least-privilege identity. No service-account keys exist anywhere.
Three servers, three scopes
| Server | Tools | Scope |
|---|---|---|
| k8s | list_pods, get_pod_events, get_pod_logs | A Role allowing only get/list on pods, logs and events in one namespace |
| finops | cost_by_service, top_cost_skus | BigQuery dataViewer on one dataset; two fixed parameterised queries, no free-form SQL |
| tickets | list/get (viewer), create/update (operator only) | Firestore datastore.user restricted by an IAM condition to one database |
Through the gateway a tool is named <server>_<tool>, such as tickets_create_ticket. The billing data behind finops is synthetic.
The token decides, not the prompt
The same agent code runs as agent-viewer (7 read tools) or agent-operator (all 9). Tool output such as a ticket description is untrusted and can contain instructions aimed at the model. The agent labels it as data and its prompt forbids following it, but that is a mitigation. The guarantee is structural: a viewer agent that is completely fooled still has no write tool in its list. The test suite plants an injection payload in a ticket to prove it, and the screenshot below shows that run.

Seven independent layers
Caller identity (JWT validation), caller authorization (role plus per-tool default-deny), gateway-to-server ID tokens, default-deny NetworkPolicies, one Google service account per workload via Workload Identity, input validation inside each tool (enums, length limits, UUIDs, clamped numbers), and an audit trail: a gateway access log plus one structured line per tool call in each server that never logs argument values.
70 live checks, four suites

| Suite | Checks | Covers |
|---|---|---|
| Flow | 30 | Discovery, tokens, garbage/untrusted-key/tampered/wrong-audience tokens (401), no role (403), per-tool authorization, input validation |
| Isolation | 19 | Each server refuses everyone but the gateway, including a genuine Google-signed token for another audience; egress limits |
| Agent | 12 | Two-server question, injection cannot make a viewer write, operator creates a ticket |
| Resilience | 9 | Kill Keycloak: same signing key afterwards. Replace the gateway pod: same policy applies |
Every "denied" check is paired with a check that the same action succeeds where it should, so a broken setup cannot pass by rejecting everything. I verified it from an empty project: down.sh, then up.sh (about 15 minutes), then the full suite passed, last on 2026-10-10. No environment is kept running.

What is deliberately out of scope
The gateway is cluster-internal, reached by kubectl port-forward; a public deployment needs TLS, a WAF and an allow-list. Keycloak runs in development mode with an in-memory database and the realm signing key in a Kubernetes Secret. Only machine clients are exercised, not human login with PKCE. An operator agent can still be manipulated into writing, because the operator role has write tools; production would need human approval for writes, rate limits and output filtering. Servers know the gateway called, not which end user is behind it. There are no rate limits or tracing at the gateway, images are not scanned or signed, and each workload has one replica.
Try it yourself
git clone https://github.com/soodrajesh/gcp-mcp-gateway-gke
cd gcp-mcp-gateway-gke
./scripts/up.sh # ~15 min: cluster, identities, images, Keycloak, servers, policies
./scripts/test.sh # 70 live checks
./scripts/ask.sh viewer "How many pods are running, and which cloud service costs the most?"
./scripts/down.sh
The GKE free-tier credit covers one Autopilot cluster's management fee; you pay for pod CPU and memory, so tear it down when finished.
📢 Have questions or feedback? Drop a comment below or connect with me on Twitter/X@spysood!