solo enterprise agentgateway · mcp plane

MCP tools, governed at the edge.

AgentGateway virtualizes MCP servers behind one URL, then applies enterprise policy: Entra JWT identity, CEL tool allow-lists, per-persona rate limits — without rewriting the MCP server.

virtual-mcp-gateway :31606 Entra JWT · tool RBAC · rate limit Entra OBO → Graph :30160/graph-me platform / sales personas

#What AgentGateway does (MCP)

The security story is entirely in gateway policies. The everything server is unchanged.

FeatureWhat it doesHow goose uses it
Virtual MCP (federation) Multi-target AgentgatewayBackend — one client session, tools from many MCP servers (namespaced by target). :31606/mcp · ./scripts/test-virtual-mcp.sh
Open vs secure paths Lab demos stay easy on open routes; security demos isolated on a second route. /mcp open · /mcp-secure locked
JWT authentication (Strict) Bearer required; JWKS validation; MCP resource metadata for discovery. Microsoft Entra · app AgentGateway-MCP-SSO
Tool RBAC (CEL) backend.mcp.authorization Allow policies filter tools/list and block calls. platform = all tools · sales = 3 tools
Sensitive-tool guardrail Same RBAC used as a practical guard — e.g. hide get-env from sales. No server change; gateway-only
Local rate limit Per-proxy counters keyed by persona (group / role). sales 8/min · platform 120/min
IdP group + app roles Policies match jwt.groups object IDs and/or jwt.roles. Mcp.Admin / Mcp.User + Entra groups
Live persona flip Demo script toggles membership + busts token cache. ./scripts/entra-mcp-groups.sh set platform|sales
Claude Code / any MCP client Streamable HTTP + Authorization header — same policies as curl. goose-mcp-entra via helper script

#Deployed config & policy

All of this lives in the k8s-goose repo under config/ (plus harness scripts). ArgoCD agentgateway-config applies it. Use the dropdown to jump to the exact file on GitHub.

GitOps path

Secure stack: mcp-secure backend + route + mcp-secure-auth / mcp-secure-tool-rbac / mcp-secure-rate-limit. Personas are Entra (not in-cluster YAML) — flip with scripts/entra-mcp-groups.sh.

#Architecture

Client presents an Entra access token. Gateway validates JWT, applies rate limit, then filters the MCP tool catalog before anything reaches the everything server.

client → virtual-mcp-gateway → policies → mcp-secure backend → everything

#Test scenarios

Scenarios 1–5 require az signed in as sebastian@maniak.io (the /mcp-secure path). Demo 1 below is the open /mcp path — no token needed. All need repo root + reachability to 172.16.10.155.

Demo 1 · Virtual MCP open federation

federation
What AgentGateway proves

One Streamable-HTTP session on the open :31606/mcp endpoint fans out to two MCP backends — mcp-server-everything and mcp-website-fetcher. The client connects once; tools/list returns a single merged catalog with tools namespaced by target (mcp-server-everything-3001_* + mcp-website-fetcher_*), and tools/call routes to the right upstream by that prefix. Add a labeled MCP Service and it appears on the same URL — no client change. No auth here; /mcp-secure (scenarios 1–5) adds Entra JWT + tool RBAC on top without breaking federation.

MCP client → /mcp :31606 → agentgateway (virtual server) → everything + website-fetcher

  1. Run the federation demo./scripts/test-virtual-mcp.sh
  2. initialize (no Authorization header)HTTP 200 + session id · serverInfo.name=agentgateway
  3. tools/list — tools from BOTH backendsprefixes from everything + website-fetcher in one list
  4. tools/call the echo toolroutes by target prefix, payload echoes back
run
./scripts/test-virtual-mcp.sh
expected
initialize → HTTP 200  serverInfo.name=agentgateway
tools/list → 14 tools · everything 13 + website-fetcher 1
  • mcp-server-everything-3001_echo
  • mcp-website-fetcher_fetch
tools/call mcp-server-everything-3001_echo → Echo: hello from virtual-mcp
 14 tools · 2 backends · 1 session

Demo 2 · Group-based tool RBAC (weather)

authorization
What AgentGateway proves

Same virtual MCP, now governed by identity. The :31606/mcp-rbac endpoint federates the everything server and a real weather MCP (Open-Meteo, OpenAPI→MCP). One CEL auth policy on jwt.groups filters tools/list per user: the Azure platform group sees the full catalog (everything + weather); the sales group sees only getWeatherForecast + echo. Everything else is hidden and denied at the edge — same backend, different catalog. Weather calls work for both groups.

group flip → new JWT → CEL on jwt.groups → filtered tools/list

  1. Sign in as platform./scripts/entra-mcp-groups.sh set platform
  2. Run — full catalog./scripts/test-virtual-mcp-rbac.sh → everything 13 + weather
  3. Flip to sales + re-runset sales then re-run → weather + echo only
run
./scripts/entra-mcp-groups.sh set platform
./scripts/test-virtual-mcp-rbac.sh

./scripts/entra-mcp-groups.sh set sales
./scripts/test-virtual-mcp-rbac.sh

./scripts/entra-mcp-groups.sh set platform   # restore
expected
platform → tools/list 14: everything 13 + open-meteo_getWeatherForecast
 platform: full federated catalog

sales → tools/list 2: mcp-server-everything-3001_echo + open-meteo_getWeatherForecast
 sales: weather + echo only — other everything tools hidden by CEL
tools/call getWeatherForecast → live forecast (both groups)

1 · JWT required (auth edge)

authentication
What AgentGateway proves

Policy mcp-secure-auth attaches Strict JWT to the HTTPRoute. No token → 401. Valid Entra token → MCP initialize.

  1. Ensure az sessionaz account show
  2. Run auth demo./scripts/test-mcp-security.sh auth
run
az account show
./scripts/test-mcp-security.sh auth
expected
anon initialize → HTTP 401  JWT token required
Entra initialize → OK  protocolVersion / serverInfo

2 · Tool RBAC — platform vs sales

authorization
What AgentGateway proves

Same URL, different catalog. Flip Entra group + app role, mint a new token (script busts cache), list tools. The everything server is never told who you are.

persona flip → new JWT → tools/list filter

platform Mcp.Admin · ~13 tools incl. get-env flip sales Mcp.User · 3 tools echo · get-sum · tiny tools/list filtered CEL on jwt.groups / roles hidden tools = Unknown tool
  1. Set platform persona./scripts/entra-mcp-groups.sh set platform
  2. List tools./scripts/test-mcp-security.sh tools → ~13
  3. Set sales + list againset sales then tools → 3
run
./scripts/entra-mcp-groups.sh set platform
./scripts/test-mcp-security.sh tools

./scripts/entra-mcp-groups.sh set sales
./scripts/test-mcp-security.sh tools

./scripts/entra-mcp-groups.sh set platform   # restore
expected
platform → tools visible (13): echo, get-env, get-sum, …
 Mcp.Admin catalog (13 tools incl. get-env)

sales → tools visible (3): echo, get-sum, get-tiny-image
 Mcp.User catalog (3 tools) — restricted set

3 · Guardrail — block get-env for sales

guardrail
What AgentGateway proves

get-env is secrets-adjacent. Sales cannot call it (not in allow list). Platform can. Same MCP server binary.

  1. Stay on (or set) sales./scripts/entra-mcp-groups.sh set sales
  2. Run guard demo./scripts/test-mcp-security.sh guard
run
./scripts/entra-mcp-groups.sh set sales
./scripts/test-mcp-security.sh guard
expected
tools/call get-env (sales) → Unknown tool / unauthorized
tools/call echo (sales)    → OK  “hello …”
# platform can call get-env successfully after set platform

4 · Rate limit (sales burns 8/min)

rate limit
What AgentGateway proves

Conditional local rate limit on the proxy: sales 8 req/min, platform 120. Counters are in-process — reset by restarting the gateway pods.

  1. Clear old counters./scripts/reset-mcp-security.sh
  2. Ensure sales persona./scripts/entra-mcp-groups.sh set sales
  3. Burn the window./scripts/test-mcp-security.sh rate
run
./scripts/reset-mcp-security.sh
./scripts/entra-mcp-groups.sh set sales
./scripts/test-mcp-security.sh rate

./scripts/entra-mcp-groups.sh set platform   # restore after demo
expected
rapid tools/list or initialize…
… → HTTP 429 rate limit exceeded
 sales local rate limit tripped

5 · Claude Code as MCP client

real client
What AgentGateway proves

Policies apply to any MCP HTTP client. Claude Code gets the same filtered catalog as the harness.

  1. Choose persona + wire MCPset platform then ./scripts/claude-code-mcp-secure.sh
  2. In Claude Code, list tools on goose-mcp-entra
  3. Flip to sales, re-wire, compare catalog
run
./scripts/entra-mcp-groups.sh set platform
./scripts/claude-code-mcp-secure.sh
claude mcp list

# in claude:
# Using goose-mcp-entra, list every MCP tool name.

./scripts/entra-mcp-groups.sh set sales
./scripts/claude-code-mcp-secure.sh   # refresh JWT
showcase prompts
Using goose-mcp-entra, list every MCP tool name.
On goose-mcp-entra, call get-env …     # platform only
On goose-mcp-entra, call echo with message "hello from entra".

Full harness (one shot)

all layers
Narrated scorecard

test-mcp-security.sh walks auth → tools → guard → rate with PASS/FAIL. Use platform first; for a clean rate story, reset between guard and rate.

presenter
./scripts/entra-mcp-groups.sh set platform
./scripts/reset-mcp-security.sh
./scripts/test-mcp-security.sh all     # or: full  (+ LLM smokes)

#Entra OBO (Graph /me)

Different story from /mcp-secure (JWT + tool RBAC only). Here the gateway exchanges the user’s Entra middle-tier token for a Microsoft Graph token via Entra’s OBO grant (urn:ietf:params:oauth:grant-type:jwt-bearer), then injects it into the graph-me MCP server. No static PAT, no elicitation UI.

token A
Middle-tier JWT
aud ≠ Graph
exchange
Entra OBO
jwt-bearer
token B
Graph token
User.Read
proof
/me profile
displayName · upn
vs other demos

What this is not

/mcp-secure — validates JWT + filters tools; no upstream exchange.

/github-elicit — URL-mode elicitation (consent UI + STS store); not Entra OBO.

/identity/impersonate — STS mint for internal tools (no Graph).

/graph-me — true Entra OBO for Graph User.Read.

why two tokens

Audience boundary

Graph rejects a middle-tier app token. The gateway swaps audiences with Entra so the MCP server never sees your client secret and the client never holds a Graph token.

bootstrap + test
./scripts/configure-entra-obo.sh   # Graph User.Read + K8s secret (once)
./scripts/test-entra-obo.sh        # mint token → initialize → graph_me

# Manual token (middle-tier audience — not the MCP SSO app)
az account get-access-token --tenant 8635e970-2205-4189-bc77-77519ff5064f \
  --resource api://0c00f6d2-5587-469d-9d35-7360d878fddc --query accessToken -o tsv

# Endpoint
http://172.16.10.155:30160/graph-me
Config

config/policies/entra-obo-policy.yaml · config/backends/graph-me-mcp.yaml · config/mcp-servers/graph-me-mcp-server.yaml · config/routes/graph-me-mcp-route.yaml · proxy STS_URI=…/oauth2/token

#Endpoints & personas

endpoints

URLs

Secure: http://172.16.10.155:31606/mcp-secure

Open (lab): http://172.16.10.155:31606/mcp

Entra OBO: http://172.16.10.155:30160/graph-me

Token (RBAC): az account get-access-token --resource api://4555f105-790c-4b59-b0f8-670a5b5654bb

Token (OBO): az account get-access-token --resource api://0c00f6d2-5587-469d-9d35-7360d878fddc

personas

platform vs sales

platform — group goose-mcp-platform + Mcp.Admin · all tools · 120/min

sales — group goose-mcp-sales + Mcp.User · 3 tools · 8/min

Flip: ./scripts/entra-mcp-groups.sh set platform|sales

ScriptRole
scripts/test-virtual-mcp.shOpen Virtual MCP federation (`/mcp` multi-backend)
scripts/test-mcp-security.shSecure path narrated harness (`/mcp-secure`)
scripts/entra-mcp-groups.shExclusive persona flip + token cache bust
scripts/claude-code-mcp-secure.shRegister goose-mcp-entra
scripts/reset-mcp-security.shRestart virtual-mcp-gateway (clear RL)
scripts/configure-entra-obo.shGraph User.Read + OBO client secret
scripts/test-entra-obo.shEntra OBO → graph_me → Graph /me
CEL (tool allow)

Edit config/policies/mcp-secure-tool-rbac.yaml — policies match group object IDs or roles. Push → Argo sync. No MCP server rebuild.

Related

LLM routing, budgets, and virtual-key cost hierarchy are on the LLM gateway page.

#OpenAPI & composable MCP Mermaid

Phase C workshop demos on virtual-mcp-gateway (distinct from open /mcp). ./scripts/test-workshop-phases.sh c · docs/flows.md

OpenAPI → MCP (Open-Meteo)

:31606/openapi-mcp
  1. initialize + tools/list
  2. Call getWeatherForecast
  3. Gateway translates OpenAPI → HTTPS REST
sequence · no custom MCP server
sequenceDiagram
  participant C as MCP client
  participant G as AGW /openapi-mcp
  participant W as Open-Meteo
  C->>G: initialize + tools/list
  G-->>C: getWeatherForecast
  C->>G: tools/call lat lon
  G->>W: GET /v1/forecast
  W-->>G: weather JSON
  G-->>C: tool result
run
./scripts/test-openapi-mcp.sh

Composable MCP

:31606/composable
  1. One tool account-brief
  2. Gateway fans out to accounts + orders
  3. CEL merges a single summary
flowchart · multi-backend one tool
flowchart LR
  C[MCP client] -->|account-brief| G[AGW /composable]
  G -->|GET /accounts/id| A[demo-apis]
  G -->|GET /orders/id| A
  G -->|merged brief| C
run
./scripts/test-composable-mcp.sh