MCP Gateway — protect any MCP server (no code changes)
Put Shield in front of an existing MCP server — third-party, vendor-built,
legacy, or your own — without changing a line of it. Your agents connect to
the gateway instead of the server; every tools/call and resources/read is
enforced (RBAC → input → forward → output DLP) before it reaches the real server.
One gateway deployment fronts many servers by config.
Use this when you don’t control the server. If you do, the embedded pattern (examples/mcp_server) is simpler.
How it works
Two separate connections — this is the thing to get right:
your agent ───▶ Shield gateway ───▶ your MCP server ("upstream")
api.guardrails… e.g. mcp.internal:9000
leg 1: public, direct leg 2: the gateway must be able to reach it
(agents point here) (and ONLY the gateway should)
- Leg 1 (agent → gateway): the gateway is your public endpoint; agents point at
https://<shield>/gateway/<route>/mcp. - Leg 2 (gateway → upstream): the gateway makes an outbound call to your server. Your server just has to be reachable from the gateway — and, for real protection, reachable only from the gateway (see Non-bypassability).
Everything is served by the Shield data plane and authenticated with your
tenant API key (X-API-Key) — no admin key involved.
Integrate in 4 steps
Prerequisites: a Shield tenant + API key (X-API-Key), and an MCP server that
speaks http (streamable) / sse / stdio.
export SHIELD=https://<your-shield-data-plane> # e.g. https://api.guardrails.votal.ai
export KEY=<your-tenant-api-key>
export ROUTE=myserver
1. Register your agent + role→tool policy (so RBAC has something to enforce)
Enforcement keys off tool names, so tell Shield which role may call which tool. Use your upstream’s real tool names.
curl -s -X POST "$SHIELD/v1/agents/registry" -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"agent_id": "my-agent",
"tools": ["search", "get_record", "delete_record"],
"role_permissions": {
"reader": ["search", "get_record"],
"admin": ["search", "get_record", "delete_record"]
}
}'
(You can also manage this + per-tool data policies in the tenant portal.)
2. Configure the route → your upstream
curl -s -X PUT "$SHIELD/v1/tenant/me/mcp-gateway/upstreams/$ROUTE" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"transport": "http",
"url": "https://your-mcp-server/mcp",
"enforcement_backend": "inprocess",
"isolation_ack": true
}'
transport:http(streamable-HTTP) /sse/stdio(command+args+env).- Upstream creds →
headers/env(redacted on read). urlmust be reachable from the gateway (notlocalhostif the gateway is remote — see Reachability).
3. Point your agents at the gateway
https://<shield>/gateway/myserver/mcp
Speaks MCP JSON-RPC. Identity comes from the connection, never from tool args:
| Header | Meaning |
|---|---|
X-API-Key |
your tenant key (which tenant’s policy) |
X-Agent-Key |
the registered agent id (my-agent) |
X-User-Role |
the caller’s role (reader / admin / …) |
GW="$SHIELD/gateway/$ROUTE/mcp"
H=(-H "X-API-Key: $KEY" -H "X-Agent-Key: my-agent" -H "X-User-Role: reader" -H 'Content-Type: application/json')
curl -s -X POST "$GW" "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# reader may not delete -> blocked, never reaches the upstream:
curl -s -X POST "$GW" "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_record","arguments":{"id":"1"}}}'
For MCP clients (Claude Desktop/Code, etc.), point a remote/streamable-HTTP connector at that URL and set the identity headers.
4. Lock the upstream down (non-bypassable)
The gateway only enforces what flows through it. If agents can reach your
upstream directly, they can skip Shield. So make the upstream reachable only
from the gateway — private network / firewall / mTLS, or a gateway-only bearer
token in the route headers that the upstream checks — then keep
isolation_ack: true (your attestation of this). A route with
isolation_ack: false starts in a warned, not-truly-protected state.
See mcp-runtime-enforcement.md.
Connect it to Claude
Point Claude at the gateway URL (not the raw upstream) with the identity headers.
Claude Code (native header support):
claude mcp add --transport http shield-gateway \
https://<shield>/gateway/<route>/mcp \
--header "X-API-Key: <tenant-key>" \
--header "X-Agent-Key: <agent-id>" \
--header "X-User-Role: admin"
Add --scope user to share it across projects (default scope is local). Manage
with claude mcp list / claude mcp get shield-gateway / claude mcp remove
shield-gateway. (The sse transport is deprecated — use http.)
Claude Desktop — edit claude_desktop_config.json (macOS:
~/Library/Application Support/Claude/). Native HTTP:
{
"mcpServers": {
"shield-gateway": {
"type": "http",
"url": "https://<shield>/gateway/<route>/mcp",
"headers": {
"X-API-Key": "<tenant-key>",
"X-Agent-Key": "<agent-id>",
"X-User-Role": "admin"
}
}
}
}
If your Desktop lacks native HTTP, bridge it with mcp-remote:
{
"mcpServers": {
"shield-gateway": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://<shield>/gateway/<route>/mcp",
"--header", "X-API-Key: <tenant-key>",
"--header", "X-Agent-Key: <agent-id>",
"--header", "X-User-Role: admin"]
}
}
}
Restart Desktop; the server appears under the connectors (🔌) menu.
Claude.ai (web) custom connectors expect OAuth, not static headers — use Claude Code or Desktop for API-key/header auth.
Notes:
- Role is fixed per connector — whatever
X-User-Roleyou set applies to every call. Add two connectors (e.g.shield-admin,shield-reader) with different roles to see allow-vs-block live. - Don’t inline secrets in a committed config. On Desktop/
mcp-remote, put the key in an env var and reference it —"--header", "X-API-Key:${SHIELD_KEY}"with"env": {"SHIELD_KEY": "<tenant-key>"}(also avoids a Windows quoting issue with spaces in header values). - Sanity-check the route first (before wiring Claude): a
tools/listcurlshould return tools, not-32004(see Troubleshooting).
Run agents in a sandbox (NVIDIA OpenShell)
Sandbox runtimes like NVIDIA OpenShell give kernel-level isolation (filesystem, process, and network egress control) but not semantic guardrails. Pair them: allowlist only the Shield gateway host in the sandbox’s egress policy, and the agent is forced through Shield with no way around it — enforcement becomes non-bypassable, by the kernel, not by trusting the agent.
Full walkthrough (policy + verified tests): Agent Sandbox (OpenShell).
Supported MCP methods
| Method | Gateway behavior |
|---|---|
initialize |
answered locally (handshake) |
tools/list |
forwarded, RBAC-filtered to what the role may use |
tools/call |
enforced (RBAC + data policy), forwarded, output sanitized |
resources/list, resources/templates/list |
forwarded (passthrough) |
resources/read |
forwarded, then DLP-checked — content treated like a tool result (redacted or blocked per role/policy) |
prompts/list, prompts/get |
forwarded (passthrough) |
notifications/* |
passed through (204) |
sampling/*, completion/*, resources/subscribe, … |
-32601 not supported (yet) |
resources/* + prompts/* are on by default (SHIELD_GATEWAY_RESOURCES=0 for
tools-only). Where no policy is configured for a method, the gateway relays it
rather than blocking.
Enforcement backends
enforcement_backend |
Where enforcement runs | Use when |
|---|---|---|
inprocess (default) |
in the gateway process | gateway co-located with Shield; lowest latency |
http |
a central Shield data plane (/v1/shield/tool/check + /tool/output; set shield_url + shield_tenant_key) |
thin-edge gateway, scaled separately; +1 round-trip/call |
Both run the same checks, so switching only changes where enforcement runs.
Reachability & deploying the upstream
The gateway connects to the upstream from the data-plane process, so a remote
gateway (e.g. api.guardrails.votal.ai) cannot reach your localhost. Options:
| Setup | url in the route |
|---|---|
| Local dev (upstream on your laptop) | expose it: ngrok http 9100 → use the public URL (+ header "ngrok-skip-browser-warning":"1") |
| Railway / Fly / Render | the app’s public URL (see examples/mcp_gateway/RAILWAY.md) |
| Same VPC / private network | the internal address (best — naturally gateway-only) |
Runnable examples to copy from: examples/mcp_gateway
(register_agent.py, bank_upstream.py, rp_upstream.py for resources/prompts).
Manage routes
curl "$SHIELD/v1/tenant/me/mcp-gateway/upstreams" -H "X-API-Key: $KEY" # list (secrets redacted)
curl "$SHIELD/v1/tenant/me/mcp-gateway/upstreams/$ROUTE" -H "X-API-Key: $KEY" # get one
curl -X DELETE "$SHIELD/v1/tenant/me/mcp-gateway/upstreams/$ROUTE" -H "X-API-Key: $KEY"
Troubleshooting
The gateway returns JSON-RPC errors; here’s what each means.
| You see | Meaning / fix |
|---|---|
-32004 no upstream configured for route 'X' |
The route isn’t created (or you deleted it). Run step 2’s PUT. Most common gotcha — starting the upstream is not the same as configuring the route. |
-32001 unauthenticated: no tenant resolved |
X-API-Key missing or not a valid tenant key on this deployment (sandbox keys like sk-test-* may not exist in prod). |
-32601 method not supported |
The method isn’t in the supported set, or SHIELD_GATEWAY_RESOURCES=0, or the deploy predates that feature. |
-32000 Blocked by Shield: Role '…' is not allowed to use tool '…' |
RBAC decision (working as intended). Register/adjust role_permissions. |
-32000 Resource content blocked by Shield data policy |
DLP decision — content withheld for that role. Expected; try a higher-clearance role. |
-32603 error handling …: <Exc> |
Upstream/transport error: upstream unreachable, or doesn’t implement that method. Check the url is reachable from the gateway. |
tools/list empty / upstream connect fails |
Upstream not reachable from the gateway (localhost from a remote gateway) or an ngrok interstitial — add "ngrok-skip-browser-warning":"1" to the route headers. |
Logs warn isolation_ack=false |
The upstream isn’t locked to the gateway — enforcement is bypassable until you fix leg 2 and set isolation_ack: true. |
Reference
Endpoints (all tenant-key auth, on the data plane):
| Endpoint | Purpose |
|---|---|
POST /v1/agents/registry |
register agent + role_permissions |
PUT/GET/DELETE /v1/tenant/me/mcp-gateway/upstreams/{route} |
manage a route |
GET /v1/tenant/me/mcp-gateway/upstreams |
list routes |
POST /gateway/{route}/mcp |
the MCP endpoint agents call |
Env flags (gateway process): SHIELD_GATEWAY_RESOURCES (default on — resources/prompts),
SHIELD_GATEWAY_FAIL_OPEN=1 (allow calls when enforcement is unreachable; default
fail-closed), SHIELD_URL / RUNPOD_TOKEN (for the http backend). The gateway
needs the mcp client SDK (already in requirements.txt).