5.2. MCP Gateway
In one glance
- You will: List the six tools the gateway lets through, watch it refuse a seventh, and watch it refuse everything when the tool server is down.
- You need: 5.1. Gateway Setup finished, with
mise run mcp:httpandmise run gateway:hoststill running. - Time: about 25 minutes, hands-on.
How is the MCP route secured?
The gateway's :3000 route names exactly six tools. Anything else is refused before it reaches the tool server.
Key term: CEL expresses the route's boolean authorization rules.
Chapter 3.3 turned the agent's read tools into a protocol server so something could sit between the caller and the tools and enforce policy on every message. The MCP route is where that promise is cashed in. The shipped :3000 route stacks two policies before the backend and relies on one check after it — from infra/agentgateway/host/config.yaml:
- port: 3000
listeners:
- name: mcp
routes:
- policies:
localRateLimit:
- maxTokens: 120
tokensPerFill: 120
fillInterval: 60s
mcpAuthorization:
rules:
- 'mcp.tool.name == "list_incidents"'
- 'mcp.tool.name == "get_incident"'
- 'mcp.tool.name == "get_service_status"'
- 'mcp.tool.name == "search_service_logs"'
- 'mcp.tool.name == "get_runbook"'
- 'mcp.tool.name == "search_runbooks"'
backends:
- mcp:
failureMode: failClosed
targets:
- name: agentops-agent
mcp:
host: http://localhost:8000/mcp
Three names in that block carry the whole page:
localRateLimitis a per-instance token bucket: a refilling allowance of requests, one spent per call.mcpAuthorizationis an allowlist expressed in CEL (Common Expression Language). Each rule is a boolean predicate evaluated against the request, and a call is admitted only if at least one predicate is true.failureMode: failCloseddecides what happens when the upstream is unreachable.
Where these rules go next
Naming the CEL layer matters, because it is the exact seam Chapter 5.5 extends: once a verified identity exists, the same rules gain a jwt.sub term (jwt.sub == "ops-admin" && mcp.tool.name == "search_service_logs") and become per-caller — same mechanism, one more clause.
One tools/call therefore passes through four gates, each with a distinct observable outcome:
sequenceDiagram
participant C as MCP client
participant RL as localRateLimit
participant AZ as mcpAuthorization (CEL)
participant BK as mcp backend (failClosed)
participant SV as MCP server
participant DB as SQLite
C->>RL: tools/call name=...
RL-->>C: 429 when the 120/min bucket is empty
RL->>AZ: token spent, evaluate mcp.tool.name
AZ-->>C: -32602 Unknown tool when no rule matches
AZ->>BK: a rule matched
BK-->>C: 500 / -32603 when the upstream is unreachable
BK->>SV: forward with the Host authority
SV-->>C: 421 when the authority is not allowlisted
SV->>DB: read-only query
DB-->>SV: rows
SV-->>C: 200 JSON-RPC result
Every one of those status codes is reproduced under its own question below, so the policy is something you verify, not something you take on faith. The next three sections reproduce three of them by hand, starting with the one that answers 200.
How do you list tools through the gateway?
With the host stack running, execute from agents/python/. Listing tools needs an MCP client rather than a curl, because the client negotiates the protocol revision first. Paste this block as is:
uv run python - <<'PY'
import asyncio
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
URL = "http://127.0.0.1:3000/mcp"
async def main() -> None:
for mode in ("legacy", "auto"): # the initialize handshake, then 2026-07-28 discovery
transport = streamable_http_client(URL, terminate_on_close=False)
async with Client(transport, mode=mode) as client:
result = await client.list_tools()
names = ", ".join(sorted(tool.name for tool in result.tools))
print(f"{mode}: protocol {client.protocol_version}: {names}")
asyncio.run(main())
PY
Expected: both lines list the six read/runbook tools in the allowlist, and no write action appears. The legacy line reports 2025-11-25, the handshake revision ADK's own client uses. The auto line reports 2026-07-28 when the gateway carries the stateless revision, which the pinned gateway does.
Deeper: why the snippet skips the closing DELETE
A handshake-era client ends its session with an HTTP DELETE. The pinned gateway (v1.4.1) accepts it with 202, but the MCP Python SDK's automatic closer recognizes only 200/204 and logs Session termination failed: 202 after a request that in fact succeeded. terminate_on_close=False skips that closer, leaving the gateway to expire the probe's session. That is fine for a one-off probe; a long-lived client should terminate explicitly and check the status itself. Whenever a pinned server and a pinned client library disagree on a protocol detail like this, prefer an explicit, status-checked call to the library's convenience path so a green run does not look red.
What does a denied tool call look like?
Ask the gateway for a tool that is not on the list. restart_service exists inside the agent but has no CEL rule on :3000:
uv run python - <<'PY'
import asyncio
from mcp import Client, MCPError
from mcp.client.streamable_http import streamable_http_client
async def main() -> None:
transport = streamable_http_client("http://127.0.0.1:3000/mcp", terminate_on_close=False)
async with Client(transport, mode="legacy") as client:
try:
print(await client.call_tool("restart_service", {"name": "api"}))
except MCPError as error:
print(f"refused: JSON-RPC {error.code}: {error.message}")
asyncio.run(main())
PY
The gateway answers with a JSON-RPC -32602 error, Unknown tool: restart_service, over HTTP 400. That negative number is a JSON-RPC error code, carried inside the HTTP response body. mise run smoke:host asserts the same refusal in both protocol eras.
The denial is total: a tool with no matching CEL rule is not merely refused on call, it is filtered out of tools/list entirely, so a client cannot even discover it exists. Remove one of the six read rules from the config, restart the gateway, and the same thing happens to that read tool — the allowlist, not the server's export list, is what the caller sees.
The checkpoint below fails the route closed on a backend outage, but that never exercises the allowlist itself: a backend-down test would pass even if mcpAuthorization were deleted. This experiment is the one that proves the allowlist matters.
What does failClosed actually decide?
Stop the MCP server and every request through :3000 fails, including the handshake. That is failClosed.
failureMode chooses what a policy point does when it cannot reach the thing it is protecting. failClosed denies the request; the alternative the schema offers, failOpen, would let it through unmediated.
Try it in three steps:
- Stop the MCP server: press
Ctrl-Cin the terminal runningmise run mcp:http. - Repeat any request — the listing script from two sections above will do.
- The gateway returns HTTP
500with a JSON-RPC-32603error naming the refused upstream connection, and even theinitializehandshake fails.
Nothing reaches a dead backend, and no caller mistakes an outage for an empty result. Restart mise run mcp:http before you continue.
For a tool gateway the right choice is not a toggle preference — it is the definition of the control. A gateway that fails open when its backend is unreachable is a gateway that stops enforcing exactly when something is already wrong, which is the moment you most need the policy to hold. The cost is honesty: failClosed means a backend blip is a hard denial rather than a silent degrade, which is the correct trade for an operations tool that reads incident state.
Common mistakes
- The script cannot reach
:3000at all. That is the gateway itself being down rather than a policy decision. Start it again withmise run gateway:host(5.1. Gateway Setup). - Every call returns
500with-32603when you did not stop anything. The gateway is up and the MCP server is not. Restartmise run mcp:http; that response isfailClosedworking, not a bug.
Why re-list tools the MCP server already restricts?
You have watched the allowlist admit six tools and refuse a seventh; here is why it exists at all.
A fair objection: mcp_server.py already exports only six read tools, so the mcpAuthorization list looks redundant. It is not, and the reason is the whole point of putting policy at a shared boundary instead of inside one client.
The agent is not the only MCP client in the cluster. infra/kagent/toolserver.yaml declares a RemoteMCPServer that points kagent — the project that manages agents as Kubernetes custom resources — at the same gateway URL:
spec:
description: Read-only incident, service, log, and runbook tools through agentgateway.
url: http://agentgateway.agentops.svc.cluster.local:3000/mcp
protocol: STREAMABLE_HTTP
timeout: 30s
kagent is a second, non-ADK consumer. It never imports the agent's Python, never sees mcp_server.py, and inherits the six-tool allowlist purely by dialing :3000.
Tighten the CEL rules once and every current and future caller of the data plane is bound by them; there is no code path where a new client can quietly acquire a seventh tool. That is the argument the allowlist encodes: policy defined once at a boundary cannot be forgotten by the next integration.
flowchart LR
ADK["ADK agent<br/>AGENT_MCP_URL=…:3000/mcp"] --> P
KA["kagent RemoteMCPServer<br/>agentops-tools"] --> P
P{{"agentgateway :3000<br/>mcpAuthorization allowlist"}} --> M["agentops-mcp:8000"]
Why are write tools absent?
restart_service and resolve_incident are never added to the MCP server in the first place. They depend on ADK's confirmation flow and audit identity, neither of which survives a translation into stateless protocol messages. They remain in the agent process and cannot be discovered or invoked through MCP at all.
The gateway allowlist and the server's export list therefore describe the same six functions from two sides: a write action is missing at the source, and even if a future edit leaked one into the server, the CEL allowlist would still refuse to route it. Keeping the read surface and the write surface physically separate is what makes the authorization boundary easy to reason about (4.5. Guardrails).
Which address does the container really dial?
The committed host config targets http://localhost:8000/mcp, but a learner who reads only that file would be looking for a value the container never uses. infra/scripts/gateway-host.sh renders a network-correct copy before it starts the pinned image, rewriting the loopback target to Docker's host bridge alias; scripts/check-infra.sh asserts the exact rewritten string:
[[ "${container_mcp}" == "http://host.docker.internal:8000/mcp" ]]
That rewrite is why the backend's Host check does not reject the gateway's forwarded requests. Underneath the CEL allowlist the MCP server runs a second, transport-layer check on the Host header, so a request forwarded from an unexpected address is refused before any protocol handling.
The k3d/GKE profiles keep this route byte-for-byte identical and only change the target to the internal agentops-mcp service.
Deeper: the Host check underneath the allowlist
The MCP server keeps DNS-rebinding protection enabled even when it binds to all interfaces, from mcp_server.py:
HOST = os.environ.get("MCP_HOST", "127.0.0.1")
PORT = int(os.environ.get("MCP_PORT", "8000"))
# MCP SDK 2.x configures transports when the ASGI app is built, not on the server object.
TRANSPORT_SECURITY = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=_allowed_hosts(),
allowed_origins=list(_ALLOWED_ORIGINS),
)
# Annotations are client-visible hints, not enforcement: the client allowlist, gateway
# policy, and in-process guarded writes remain the security boundary.
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
mcp = MCPServer("agentops-agent")
The _DEFAULT_ALLOWED_HOSTS tuple in the same file explicitly lists host.docker.internal, agentgateway, agentgateway.agentops.svc.cluster.local, agentops-mcp, and the loopback forms — precisely the authorities each profile forwards. _allowed_hosts() reads MCP_ALLOWED_HOSTS as a comma-separated full override (not an addition), so a deployment can narrow the set without ever falling back to *. Send a Host header the server does not expect and it answers 421 Misdirected Request before any protocol handling: a second, transport-layer defense underneath the gateway's CEL allowlist.
How does the deployed agent select this path?
The Kubernetes agent flips to the gateway with one variable, from infra/kagent/agent.yaml:
AGENT_MCP_URL=http://agentgateway:3000/mcp
root_agent then replaces its local read/knowledge functions with a single remote toolset; guarded writes and the instruction-only skills stay in-process. A secured route (Ch. 5.5) needs a second variable, AGENT_MCP_TOKEN, and both transports carry the course deadline so a hung gateway fails a turn instead of hanging it — from mcp_client.py:
endpoint = url or settings.mcp_url
if endpoint:
# A secured gateway route (Ch. 5.5) authenticates the caller by bearer
# token; the default local route needs no header.
headers = {"Authorization": f"Bearer {settings.mcp_token.get_secret_value()}"} if settings.mcp_token else None
return McpToolset(
connection_params=StreamableHTTPConnectionParams(
url=endpoint,
headers=headers,
timeout=settings.tool_timeout_s,
sse_read_timeout=settings.tool_timeout_s,
),
)
settings.tool_timeout_s (30 s) becomes both timeout and sse_read_timeout, so a stalled gateway is a fast tool failure, never an unbounded wait.
mcp_token is a SecretStr: a type that masks its value wherever configuration is printed. It is absent when unset, so no empty Authorization header leaks onto the open local route.
Owned by mcp_client.py; the same excerpt is build-checked into 3.3. MCP.
What does the rate limit guarantee?
maxTokens: 120, tokensPerFill: 120, fillInterval: 60s is a token bucket that refills to 120 every 60 seconds: 120 requests per minute, per gateway instance. Send more and the surplus returns HTTP 429 until the next refill.
Be honest about what this control is: it belongs to one instance and has no authenticated-user dimension, so it blunts accidental bursts in this single-replica lab but is not a per-tenant quota. A production budget needs identity, a shared policy/quota store, and a decision for rejected versus queued work (5.5. Gateway Security).
Deeper: how the load test stays inside the same budget
That number is a shared contract, not a loose default — load/mcp-read.js encodes the same budget as a hard threshold so the load test measures the platform rather than its own throttle:
mcp_rate_limited: ['count==0'], // any 429 means the gateway budget, not the platform, was measured
The k6 load-test scenario defaults to 60 tool calls per minute, comfortably under 120, so a single 429 fails the run and tells you the request rate crossed the gateway budget instead of surfacing real backend latency.
Your turn: how do you take one tool off the allowlist?
This is the chapter's required drill, and the chapter checkpoint gates it. Prove that the CEL allowlist, not the MCP server's export list, decides what a caller reaches.
- Mode:
temporary experiment. - Goal: delete the
search_service_logsrule from the:3000route, watch that tool disappear fromtools/listwhilemcp_server.pystill exports it, then restore the rule. - Files to touch: one line under
mcpAuthorization.rulesininfra/agentgateway/host/config.yaml, and nothing underagents/python/. That is the point: no code change is needed to take a tool away from every caller at once. - Preflight: require
git diff --quiet -- infra/agentgateway/host/config.yaml, and stop if the host gateway or MCP process belongs to another active task. - Gate that proves completion: restart the gateway, leave
mise run mcp:httpalone, and run the listing script from How do you list tools through the gateway? again. It prints five names, andsearch_service_logsis gone even though the untouched MCP server still exports it. Restore the rule, restart the gateway once more, and the six names come back. No model is involved, so this loop is free to repeat. - Final state: run
git restore -- infra/agentgateway/host/config.yaml, restart only the gateway you own, confirm all six names return, and requiregit diff --quiet -- infra/agentgateway/host/config.yaml.
What proves this page worked?
Stop the raw MCP process and repeat the list request: the gateway must fail closed with 500/-32603. Restart it, confirm the six tools return, and additionally call restart_service by name to confirm the allowlist denies it with -32602 Unknown tool. Inspect gateway logs and :15020 metrics for each outcome — a denial, a rate-limited 429, and a fail-closed 500 should all be visible as gateway decisions, not inferred from the client alone.
You are done when:
- The listing script printed exactly six names:
get_incident,get_runbook,get_service_status,list_incidents,search_runbooks, andsearch_service_logs. - Calling
restart_servicethrough:3000returned a JSON-RPC-32602error,Unknown tool: restart_service, over HTTP400. - With
mise run mcp:httpstopped, the same listing script failed with500/-32603instead of returning an empty list. mise run mcp:httpis running again and the six names come back.- The
## Your turndrill is done: with one CEL rule deleted the listing printed five names whilemcp_server.pywas never touched, and restoring the rule brought the sixth back.
Continue to 5.3. A2A Gateway when the gateway refused the seventh tool without the MCP server ever being asked.