Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PatternBest forRisk if overused
API gateway in front of the MCP endpointCentral auth, TLS, rate limits, per-tool allowlists from Mcp-NameTurning the gateway into an opaque second application
Identity-aware proxyUser or service identity handoff, coarse access controlTrust mapping becomes unclear if headers or claims are loosely managed
Sidecar or service mesh controlsEast-west policy, mutual TLSHeavy operational complexity for a small deployment
Custom governance proxyTenant routing plus policy that no off-the-shelf gateway expressesRe-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

  1. Give the server a clear surface with explicit tool families and risk levels.
  2. Put token validation, tenant resolution, rate limits and the tool firewall in the gateway.
  3. Keep tool semantics and validation in the server, and have it read the same allowlist to filter its catalog.
  4. Propagate tenant and trace context explicitly to downstream systems.
  5. Build audit views that join gateway refusals and server events on trace_id.

Failure Modes

  1. The gateway rewrites what the server validates. A proxy strips Mcp-Name, normalizes MCP-Protocol-Version, or re-serializes the body with a different _meta. Every call fails with 400 HeaderMismatch (-32020) or UnsupportedProtocolVersionError, and the server team sees an outage they did not cause. Detect it with a synthetic tools/call through the gateway on every gateway config change, alerting on any -32020.
  2. Deny without hide. The firewall refuses close_ticket, tools/list still advertises it, and the model retries the refused tool or tells the user it is broken. Detect it by comparing the tenant’s tools/list against the allowlist in CI; any listed tool that the gateway would refuse is a bug.
  3. Tenant from a header the client controls. The gateway routes on X-Tenant-Id sent by the client, or on an Mcp-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-Name and MCP-Protocol-Version through 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-Name values and fails closed for unknown tenants.
  • Refusals are JSON-RPC errors with the request id and 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/list from the same allowlist and returns cacheScope: "private".
  • subscriptions/listen has 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.