Protect MCP servers with the gateway
The MCP Gateway is a config-driven Policy Enforcement Point (PEP) that exposes backend APIs as MCP tools and resources, with access control enforced by the PBAC Authorization Server on every call.
How it works
MCP Client (Claude Code)
│ Bearer token
▼
┌─────────────────────────┐
│ MCP Gateway (PEP) │
│ Introspects token on │
│ every tool call │
└──────────┬──────────────┘
│ POST /introspect + context
▼
┌─────────────────────────┐
│ PBAC AS (PDP) │
│ OPA evaluates policy │
│ active: true/false │
└─────────────────────────┘
The gateway passes a context object to /introspect containing the required scope for the tool or resource being called. The AS evaluates OPA policy and returns active: true or active: false. The gateway enforces the result — it never makes authorization decisions itself.
Policy lives entirely in OPA and can be updated without restarting the gateway or reissuing tokens.
Setup
1. Create a PBAC instance
./pbac create myenv
This starts an Authorization Server, OPA sidecar, and MySQL database as a named instance.
2. Add a gateway
./pbac gateway add myenv --config mcp-gateway/gateway.yaml --name my-gateway
This command:
- Seeds the gateway Rego policies and policy data into the AS
- Starts the gateway container pointed at the AS
- Prints the
claude mcp addcommand for the next step
3. Connect Claude Code
./pbac gateway connect myenv my-gateway
This prints and optionally runs the claude mcp add command with the correct transport, client ID, and gateway URL. Claude Code will use the OAuth authorization code flow (with PKCE) to obtain a token before making tool calls.
Configuration
gateway.yaml defines which backends to expose and what scopes each tool requires:
gateway:
port: 9090
as_issuer: "http://localhost:8080"
as_introspect: "http://localhost:8080/introspect"
resource_indicator: "urn:mcp:gateway"
backends:
- name: drive
url: "http://localhost:9091"
tools:
- name: drive_list_files
scope: drive:read
description: "List files in Google Drive"
input_schema:
type: object
properties:
folder_id: { type: string }
resources:
- uri: "drive://files"
scope: drive:read
name: "Drive Files"
Every tool and resource has a scope field. The gateway sends this scope to the AS during introspection. If the token does not carry that scope (or policy denies it), the gateway returns a 403.
See mcp-gateway/docs/configuration.md in the repository for the full gateway.yaml reference.
How policy controls access
Access control is defined in two places:
- Policy rules (
gateway.rego) — Rego logic that maps subjects to roles to scopes. - Policy data (
data.gateway) — Role and user assignments, stored in the AS and served via the OPA bundle.
The default policy uses a role-scopes model:
{
"role_scopes": {
"engineering": ["drive:read", "drive:write", "jira:read", "jira:write", "slack:read", "slack:write"],
"analytics": ["drive:read", "jira:read", "slack:read"]
},
"user_roles": {
"alice": ["engineering"],
"bob": ["analytics"]
}
}
To update access control, update the policy data via the Admin API:
curl -X PUT "http://localhost:8080/admin/api/policy-data/by-key?dataKey=gateway" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"role_scopes": {...}, "user_roles": {...}}'
OPA picks up the change on its next bundle poll (default: 10 seconds). No restart or token reissue needed.
See mcp-gateway/docs/policy.md in the repository for the full policy reference including custom roles, per-user overrides, and writing custom Rego.
Demo
demo/mcp-gateway/ contains a complete walkthrough with three fake backends (Drive, Jira, Slack) and three users (Alice, Bob, Charlie) with different roles. It demonstrates live policy changes taking effect without restarting or reissuing tokens.
Next steps
- AI Agents Use Case — Trust tiers, single-use tokens, and agent authorization patterns
- Token exchange — Scope narrowing and cross-domain identity via RFC 8693
- Policy Data — Configure gateway role-scope mappings and user assignments