Chapter 26: Enterprise Middleware & Gateways
On a Tuesday, security asks for close_ticket to be switched off for every contractor tenant by Friday, without a deploy of the tickets server, and with an audit record of every blocked attempt. That request tells you whether you have a policy layer or just a reverse proxy. With a proxy, you open a ticket with the server team and wait for a release. With a policy layer, you add one line to an allowlist and the gateway starts refusing the call within the minute.
The rule for this chapter: the gateway decides which tools a tenant may call, from headers and a platform-owned allowlist; the server decides what those tools do. The gateway never reads tool semantics, and the server never has to know which tenants security has restricted this week.
What a Gateway Can See in MCP Traffic
Before 2026-07-28, a gateway in front of an MCP server saw one opaque endpoint. Every call was a POST to /mcp with a JSON-RPC body, so per-tool policy meant buffering and parsing every request. The current revision changes four things that matter at the gateway.
Method and tool name are headers. Every POST carries Mcp-Method (tools/call, tools/list, resources/read, …), and calls that name a target carry Mcp-Name (the tool name for tools/call, the URI for resources/read, the prompt for prompts/get). MCP-Protocol-Version is on every POST too. A gateway can route, rate-limit and deny per tool without touching the body. Names that are not header-safe arrive Base64-wrapped as =?base64?...?=, and the gateway must decode them before comparing.
Headers are only trustworthy because the server checks them. The spec requires a 2026-07-28 server to reject any request whose headers disagree with its body, with HTTP 400 and JSON-RPC error -32020 (HeaderMismatch). That is what stops a client from sending Mcp-Name: search_tickets with a body that calls close_ticket. It only holds on the current revision, so a gateway enforcing policy on these headers should reject requests whose MCP-Protocol-Version predates header validation.
There is no session to stick to. Mcp-Session-Id is gone, and so is the initialize handshake. Any replica can serve any request, so the gateway needs no sticky sessions and scales the server like any stateless HTTP service. The one long-lived connection is a subscriptions/listen stream for change notifications. Give that route a longer idle timeout than tool calls, and do not buffer it.
Version, capabilities and trace context travel in the body’s _meta. Each request carries io.modelcontextprotocol/protocolVersion and clientCapabilities in params._meta, and may carry traceparent. The protocol version must match the header. A gateway that rewrites bodies, or “normalizes” the version header, breaks every call. The gateway cannot read _meta without parsing, so it should continue or start a trace with an HTTP traceparent header and leave the body alone. The server prefers _meta.traceparent when present and falls back to the header (Chapter 23).
The consequence: a gateway can enforce per-tenant, per-tool policy from headers alone, and read the body only on the path where it refuses the request.
Where to Put the Policy Layer
| Pattern | Best for | Risk if overused |
|---|---|---|
| API gateway in front of the MCP endpoint | Central auth, TLS, rate limits, per-tool allowlists from Mcp-Name | Turning the gateway into an opaque second application |
| Identity-aware proxy | User or service identity handoff, coarse access control | Trust mapping becomes unclear if headers or claims are loosely managed |
| Sidecar or service mesh controls | East-west policy, mutual TLS | Heavy operational complexity for a small deployment |
| Custom governance proxy | Tenant routing plus policy that no off-the-shelf gateway expresses | Re-implementing server logic outside the server |
Use an off-the-shelf gateway that can match request headers. Write a custom proxy only when the policy needs data the gateway cannot reach, and keep it to the firewall below plus token validation.
Middleware is good at controls that should behave the same across many servers: token validation, tenant resolution from trusted identity, rate limits and quotas, trace headers, and allow or deny by tool name. It is bad at core business logic, response construction, and anything that needs to understand a tool’s arguments. Keep the policy layer smart enough to enforce policy and boring enough to operate.
The Tool Firewall
A tool firewall is a control plane that can disable a tool, or a family of tools, per tenant and environment, without a server deploy. On the current revision it is a header match. This one is ASGI middleware in front of the tickets server:
import base64
import json
ENVIRONMENT = "prod" # this gateway's own deployment, never a request header
ALLOW = { # platform-owned, reviewed like firewall rules; annotations never feed it
("acme", "prod"): {"search_tickets", "get_ticket"},
("acme", "pilot"): {"search_tickets", "get_ticket", "close_ticket"},
}
def tool_name(raw: str) -> str: # Mcp-Name may arrive Base64-wrapped
if raw.startswith("=?base64?") and raw.endswith("?="):
return base64.b64decode(raw[9:-2]).decode()
return raw
class ToolFirewall:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
h = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
if scope["type"] == "http" and h.get("mcp-method") == "tools/call":
tenant = scope["state"]["tenant_id"] # set by the gateway's token check
if h.get("mcp-protocol-version") != "2026-07-28":
return await deny(receive, send, "Upgrade the client to MCP 2026-07-28.")
if tool_name(h.get("mcp-name", "")) not in ALLOW.get((tenant, ENVIRONMENT), ()):
return await deny(receive, send, "That tool is not enabled for your organization.")
return await self.app(scope, receive, send)
async def deny(receive, send, message):
body, more = b"", True
while more: # read the body only on the deny path, to echo the request id
event = await receive()
body, more = body + event.get("body", b""), event.get("more_body", False)
error = {"jsonrpc": "2.0", "id": json.loads(body or b"{}").get("id"),
"error": {"code": -32010, "message": message}}
await send({"type": "http.response.start", "status": 403,
"headers": [(b"content-type", b"application/json")]})
await send({"type": "http.response.body", "body": json.dumps(error).encode()})
The refusal is a JSON-RPC error with the request’s id, so the client can match it to the call it made and show the model a sentence it can act on. The code, -32010, sits in the range the spec leaves to implementations (-32000 to -32019); -32020 and up belong to the spec. An unknown tenant gets an empty allowlist, so the firewall fails closed.
Tested against the official Python SDK’s Streamable HTTP app, an allowed call passes through untouched, a denied call returns the 403 above, and a request that sends Mcp-Name: search_tickets with a body calling close_ticket gets past the firewall and is then refused by the server with -32020. The two layers are only safe together. If the server behind your gateway is not yours, confirm it performs that header-body check before relying on header policy.
The allowlist names tools explicitly. It does not say “allow everything with readOnlyHint: true”, because readOnlyHint is the server’s claim about itself. For servers you own, annotations help reviewers write the allowlist. For servers you don’t own, a server that lies about being read-only would walk straight through a firewall built on its own annotations.
Enforce at the Gateway, Hide at the Server
A firewall that refuses close_ticket while tools/list still advertises it wastes a round trip on every attempt, and the model keeps choosing the tool because its description still fits the task. The tool should disappear from the catalog for that tenant.
The decision: the gateway enforces tools/call; the server hides tools in tools/list by reading the same allowlist file, and marks the list cacheScope: "private" because it now varies by tenant (Chapter 25). Filtering tools/list at the gateway would mean buffering and rewriting response bodies, some of them SSE streams, which is exactly the body-rewriting this chapter warns against. The exception is a third-party server you cannot change: there the gateway filters tools/list itself and forces cacheScope to "private" on the way out.
Identity Propagation: Each Hop Adds Clarity
By the time a request reaches the ticket database, you may need to distinguish the client application, the human or service principal, the effective tenant, and any break-glass elevation. If all of that flattens into “user”, incident review becomes guesswork.
Each hop should add clarity, not reinterpretation. The gateway validates the token and resolves the tenant. The server builds the same AuthContext from the same token and applies tool-level policy. Downstream systems enforce the permissions they own. A gateway narrows who gets in; it cannot repair broken tenant checks behind it (Chapter 21 makes the same point from the incident side). Layer ownership for tenant scope is in Chapter 25’s table.
Approval and Content Controls Stay Legible
Enterprises often want approval policy in the gateway so it can change without redeploying handlers. The gateway may flag that a class of tools requires approval and record the approval context. It must not guess whether an action is dangerous from raw arguments, or silently rewrite requests. If approvals feel random to users, they stop trusting the system, even when it is technically safer.
Content controls follow the same line. A gateway can keep sensitive fields out of logs and apply egress rules for regulated environments. It cannot know business meaning: a DLP rule that redacts email fields will gut a CRM tool whose whole job is returning contacts. If your gateway constantly has to “correct” the server, the interface design is the real problem.
Adoption Sequence
- Give the server a clear surface with explicit tool families and risk levels.
- Put token validation, tenant resolution, rate limits and the tool firewall in the gateway.
- Keep tool semantics and validation in the server, and have it read the same allowlist to filter its catalog.
- Propagate tenant and trace context explicitly to downstream systems.
- Build audit views that join gateway refusals and server events on
trace_id.
Failure Modes
- The gateway rewrites what the server validates. A proxy strips
Mcp-Name, normalizesMCP-Protocol-Version, or re-serializes the body with a different_meta. Every call fails with 400HeaderMismatch(-32020) orUnsupportedProtocolVersionError, and the server team sees an outage they did not cause. Detect it with a synthetictools/callthrough the gateway on every gateway config change, alerting on any-32020. - Deny without hide. The firewall refuses
close_ticket,tools/liststill advertises it, and the model retries the refused tool or tells the user it is broken. Detect it by comparing the tenant’stools/listagainst the allowlist in CI; any listed tool that the gateway would refuse is a bug. - Tenant from a header the client controls. The gateway routes on
X-Tenant-Idsent by the client, or on anMcp-Param-*header mirrored from a tool argument, instead of the tenant claim in the verified token. Any caller can then name any tenant, and every downstream control inherits the lie. Detect it with a hostile test that sends a valid token for tenant A and a header naming tenant B, expecting refusal.
Implementation Checklist
- The gateway passes
Mcp-Method,Mcp-NameandMCP-Protocol-Versionthrough unmodified and never rewrites request bodies. - The tool allowlist is keyed by tenant and environment, owned by the platform team, and changed without a server deploy.
- The firewall decodes Base64
Mcp-Namevalues and fails closed for unknown tenants. - Refusals are JSON-RPC errors with the request
idand a code in-32000..-32019. - Requests on protocol versions before 2026-07-28 are refused on routes that enforce header policy.
- The server filters
tools/listfrom the same allowlist and returnscacheScope: "private". -
subscriptions/listenhas its own idle timeout and no response buffering. - Gateway refusals are logged with tenant, tool and
trace_id.
What Comes Next
The firewall works because every call arrives labelled with a tenant and a tool name, and the layer that owns the allowlist is the one that can change it on a Tuesday. When those lines are clear, governance becomes repeatable; when they are fuzzy, governance turns into meetings. A gateway that knows tenant and tool on every call is also the cheapest place to start counting what each call costs, which is the subject of Chapter 27.