The 2026-07-28 revision of the Model Context Protocol (referred to here as 7-28) moved more of MCP from an application concern toward an infrastructure one, and the practical consequence is that more of the primitives you now have to operate live on your side, in the platform and not exclusively at the app team's layer.
"It's stateless now" is an oversimplification. The spec now defines routable headers, cache hints, trace-context keys, and per-request metadata: primitives platform teams can actually operate.
Let us look at the five levers that landed on your side of the line.
The routing rule you couldn't write before
So a security team might ask you for a policy at some point that goes like this: "no more than 10 execute_sql calls per tenant per minute and never in eu-central-1 from the staging fleet." And chances are, you said no, because enforcing it was unreasonable.
Before 7-28, a rule like that meant inspecting JSON bodies at the edge. Your gateway had to terminate TLS, parse the full JSON-RPC payload, fish out params.name and then an argument buried somewhere in params.arguments, and only then make a decision. That's technically expensive, fragile, and on most WAFs are not even an option. So in most cases we have seen, such rules never got written and MCP traffic ran through the front door ungoverned.
After 7-28, the same rule is a header match. The method sits in Mcp-Method, the tool name in Mcp-Name, the region in Mcp-Param-Region, all visible before a single byte of the body is read.
Rate limiting per tenant and tool? A counter keyed on two headers.
Blocking a tool in one region from one fleet? A header predicate.
Rules your infrastructure has enforced for HTTP traffic for a decade suddenly apply to MCP as well.
That before-and-after is really the whole post in miniature: the protocol finally grew the affordances that let ordinary, commodity infrastructure do its job.
Why this is infrastructure now
MCP stopped assuming a connection means anything. Mcp-Session-Id is gone. The GET stream endpoint is gone. The initialize handshake is gone. In the old model, context was established once at connection time and every subsequent request inherited it implicitly, which meant only the server holding that session could act on it. What replaces the session is per-request metadata: version and capabilities in _meta, the operation in Mcp-Method and Mcp-Name, the identity in Authorization, all on every request.
That's the shape infrastructure can act on, because a stateless self-describing request is one any node, any proxy, and any policy engine can read and route without shared memory.
Craig McLuckie made the high-level version of this case in 7-28 Hands MCP to Platform Teams, and Akash Jaiswal walked the migration arc in MCP 2026-07-28: From Local Tool to Distributed Protocol. This post wants to shine light on these specific levers.
Lever 1: Routing and policy without body inspection
Streamable HTTP POSTs now use two standard routing headers: Mcp-Method, mirrored from the JSON-RPC method, on all requests, and Mcp-Name, mirrored from params.name (tools) or params.uri (resources), required on tools/call, resources/read and prompts/get. Header names are case-insensitive; the values are case-sensitive.
The underrated part is custom parameter headers. A server annotates a primitive tool parameter with an x-mcp-header extension in the tool's inputSchema and conforming clients MUST mirror that argument's value into a header named Mcp-Param-{Name}.
The spec's own example is execute_sql on Cloud Spanner:
{
"name": "execute_sql",
"description": "Execute SQL on Google Cloud Spanner",
"inputSchema": {
"type": "object",
"properties": {
"region": {
"type": "string",
"description": "The region to execute the query in",
"x-mcp-header": "Region"
},
"query": {
"type": "string",
"description": "The SQL query to execute"
}
},
"required": ["region", "query"]
}
}A call with region: "us-west1" travels with Mcp-Param-Region: us-west1. Your gateway can now rate-limit and route on tool arguments the server chose to publish: region, tenant, environment, priority. That's strong!
The body stays the source of truth. Any server that processes the body MUST reject a header/body mismatch with HTTP 400 and JSON-RPC error code -32020 (HeaderMismatch).
One caution: per the SEP-2243 spec page, this code was originally assigned as -32001 and "reassigned from -32001 to -32020" by the error-code allocation update in PR #2907; the C# SDK docs and parts of the SEP body still show the old value, and the same renumbering moved MissingRequiredClientCapability from -32003 to -32021 and UnsupportedProtocolVersion from -32004 to -32022. Match on -32020.
Now the caveat that makes this lever credible. Header enforcement only means something if the request actually speaks 7-28. SEP-2243 is explicit: "Intermediaries that enforce policy based on mirrored headers (e.g., routing or rate-limiting by tenant) SHOULD verify that the MCP-Protocol-Version header indicates a version that requires header-body validation. If the version is older or the header is absent, the intermediary SHOULD reject the request rather than trusting unvalidated header values." Skip that check and a client speaking 2025-11-25 walks straight past your edge policy with headers nobody validated against a body.
Your todo, right now: Write that guard into the gateway config today.
One more decoding gotcha: non-ASCII, leading/trailing-whitespace and control-character values are Base64-encoded inside a sentinel, Mcp-Param-{Name}: =?base64?{value}?=. The markers are lowercase and exact. Your header-matching rules MUST decode the sentinel before comparing, or a policy on a region name like São Paulo silently misses.
Here is the artifact, in agentgateway's standalone YAML with tenant/region routing plus the version guard:
# yaml-language-server: $schema=https://agentgateway.dev/schema/configgateways:
default:
port: 3000
protocol: HTTProutes:
# Route EU-region execute_sql traffic to the EU backend,
# on the mirrored header. The specific route comes first:
# routes are evaluated in the order they appear.
- name: mcp-eu-region
matches:
- path:
pathPrefix: /mcp
headers:
- name: mcp-param-region
value:
regex: "^eu-.*"
policies:
# Version guard: only 7-28 traffic is allowed
# to be policy-routed here.
# require fails closed, so an older or absent
# version is denied.
authorization:
rules:
- require: 'request.headers["mcp-protocol-version"] == "2026-07-28"'
backends:
- mcp:
targets:
- name: mcp-eu
host: mcp-eu.internal:8080# Catch-all comes last
- name: mcp-default
matches:
- path:
pathPrefix: /mcp
backends:
- mcp:
targets:
- name: mcp-default
host: mcp.internal:8080The header-match block and the CEL authorization rule are both real agentgateway syntax. The exact version-guard rule is illustrative: treat it as the shape and decide per environment whether to deny outright or route older traffic to a body-inspecting path.
Lever 2: Capacity planning after affinity goes away
Any POST can now hit any pod. No sticky sessions, no shared session store, no Last-Event-ID resumability. Classic horizontal-scaling: a round-robin load balancer in front of a stateless pool is now a correct MCP deployment.
But what is important to highlight is that stateless does not mean no long-lived connections. subscriptions/listen is still a single long-lived POST-response stream that clients open to receive opted-in change notifications (toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions). Cancellation on Streamable HTTP is simply closing the stream. And because resumability is gone, a broken response stream loses the in-flight request; the client MUST re-issue it as a brand-new request with a new request ID.
So you have two traffic classes with completely different profiles. Class one is short, bursty RPCs: tools/call, tools/list, resources/read. You can bin-pack these, scale on request rate and let any replica take any request.
Class two is subscriptions/listen: long-lived streams that pin a connection for their lifetime, want X-Accel-Buffering: no so intermediaries stop buffering, want periodic keep-alive comment lines so proxies do not hang up during a quiet spell, and want generous idle timeouts on the load balancer.
Be aware of the consequences: request-rate scaling for class one and concurrent-connection scaling for class two.
Mixing them on one deployment is a capacity mistake teams can make. A burst of RPC traffic will scale a deployment that is also holding thousands of listen streams and either your streams get evicted on scale-down or your RPC latency suffers behind saturated event loops. Consider splitting them into two deployments with two HPA policies. The capacity arithmetic is an art of its own, not covered here.
Lever 3: Edge caching on discovery traffic
New is the CacheableResult interface carrying two required fields on the results of tools/list, prompts/list, resources/list, resources/read and resources/templates/list:
ttlMs, a freshness hint in millisecondscacheScope, either"public"or"private".
This model is lifted straight from HTTP Cache-Control. For 7-28, both fields are required; per SEP-2549, if ttlMs is negative the client SHOULD treat it as 0, meaning immediately stale.
cacheScope: "public" is an explicit invitation for shared intermediaries to cache. That is a CDN or gateway decision, not an application decision (hello again to team infra) and it lands on the highest-volume lowest-value traffic on the wire. Caching those at the edge can reduce latency and upstream load.
But, there is a big but: a poisoned or stale tool definition cached at public scope reaches every client for the full TTL and tool definitions steer model behavior: a tampered description or a rewritten schema turns into a prompt-injection vector. So two rules. First, whenever scope is private, a shared cache MUST keep responses separated by authorization context; a per-tenant tool list marked private and cached without keying on identity serves one tenant's catalog to another. Second, treat a wrong cacheScope from a server team (a per-tenant list marked public) as a security bug, not a performance bug and gate it in review.
What I love about this is a little win hidden in this SHOULD. Servers SHOULD return tools/list in a deterministic order. Stable ordering helps keep the serialized tool catalog stable across fetches, which can lift LLM prompt-cache hit rates downstream: the model's context prefix stays stable, so the provider's prompt cache stays warm. It is cheap, it shows up directly in latency and token cost and nobody is talking about it.
Lever 4: The application state you now own
Stateless doesn't mean state vanishes; I see it more like it changed owner. One replacement pattern for session state is a server-minted explicit handle passed back as an ordinary tool argument, the same shape as a well-designed REST API where create_basket returns a basket_id the caller quotes on the next call (SEP-2567). The server owns the state behind the handle and, where authorization is used, checks the handle against the authorization context on every call.
That turns the handle into something valuable to protect. It rides through model context: visible to the model and to anything that can influence the model's input. A handle can sit in a place a prompt injection can read and replay. For authenticated servers, possession of the handle should not grant access by itself: validate it against the authorization context on every call. For unauthenticated servers, the handle is a bearer token and should be unguessable with a bounded lifetime. That is platform work, not tool-author work. Tool authors will reach for a raw database ID and move on.
A related rule applies to MRTR requestState. (I wrote earlier about it https://aaif.io/blog/designing-requeststate-for-multi-round-trip-requests) Under Multi Round-Trip Requests (SEP-2322), a server returns an InputRequiredResult with resultType: "input_required" and an inputRequests map, plus an opaque requestState the client MUST echo back verbatim; the client re-issues the original call (with a new request ID) carrying inputResponses and the echoed state. Because requestState round-trips through a client-controlled channel, treat it as attacker-controlled: validate it on every retry, protect its integrity where needed, bind user-specific state to the principal, and reject the round when verification fails.
Let me frame that differently for you. You are not deleting the session store. You are replacing an implicit session, hidden in the transport where you could not see it, with explicit state you can validate, authorize and audit. Sounds better to me than "the sessions are gone" and you will make your security team happy, at least for a moment.
Lever 5: Authorization hardening
I sneak peaked this one already, but let's elaborate it more. For authenticated HTTP deployments, Authorization is carried on every request. That was already true before 7-28; the change here is the authorization hardening around issuer validation, credential binding, and client registration.
How to fix this? One option is to cache validation results, keyed by token, rather than calling your introspection endpoint on every request. The cache TTL then becomes your revocation window, so pick it deliberately: a 60-second TTL means a revoked token keeps working for up to 60 seconds. In the context of agents, that might feel like half a decade.
So, give your IdP team some hints, concretely:
- RFC 9728 Protected Resource Metadata is mandatory for servers using HTTP authorization. Clients discover the authorization server from a
WWW-Authenticatechallenge or the/.well-known/oauth-protected-resourceURI. - RFC 8707 resource indicators on every authorization and token request and servers MUST validate the token's intended audience on every call. The resource indicator helps stop a token minted for one MCP server from being replayed against another.
- RFC 9207
issvalidation before code redemption. Clients MUST validate a presentissagainst the recorded issuer, closing an authorization-server mix-up hole (SEP-2468). - Credentials keyed by issuer (SEP-2352): clients MUST key persisted credentials by the issuer identifier, MUST NOT reuse them across authorization servers and MUST re-register when the AS changes.
application_typeon Dynamic Client Registration (SEP-837), so authorization servers stop rejecting localhost redirects for desktop and CLI clients.
I'm also not fully into this part, but it sounds to me like a shift: Per the 7-28 authorization spec, DCR is "deprecated and retained for backwards compatibility with authorization servers that do not support Client ID Metadata Documents." Operationally, CIMD means your authorization server now fetches a document from a URL the client controls, validates that the document's client_id equals the URL, checks each requested redirect_uri against its allowlist, and caches it. Now, CIMD might be not supported yet; the official MCP blog frames the new "twelve-month minimum window" as time "to plan upgrades instead of reacting to them," with DCR continuing to work "for backward compatibility" until it is removed in a future spec version. Nothing to rush at, but to be considered in the long run,
By the way, a server returns 403 with WWW-Authenticate: Bearer error="insufficient_scope" and a scope parameter listing what is needed and the client re-authorizes with the union of scopes. However, a gateway that strips or rewrites WWW-Authenticate breaks step-up silently, so the client never learns which scope to request. A simple fix, but it might be overlooked a lot right now: allow that header through your proxy.
Lastly, one thing I didn't want to spend another section on but that is important for observability: W3C trace context (traceparent, tracestate, baggage) is now carried in _meta with fixed key names (SEP-414). Propagate it from _meta into your tracing backend and a trace that starts in the host app follows the tool call through the client, the gateway, your MCP server and downstream services as one span tree.
Migration checklist
Now, I know, this was a mouthful so I created a short checklist of who needs to address what and where:
| # | Action | Owner |
|---|---|---|
| 1 | Inventory MCP endpoints and classify each: RPC traffic vs subscriptions/listen | Platform |
| 2 | Enforce MCP-Protocol-Version at the edge; reject anything older or route it to a body-inspecting policy path | Gateway |
| 3 | Header-to-policy rules on Mcp-Method / Mcp-Name, with Base64 sentinel decode | Gateway |
| 4 | Consider separate deployments/HPA by traffic class; drop session affinity config | Platform |
| 5 | Cache cacheScope: public list responses at the edge; keep private results separated by authorization context | Gateway |
| 6 | Review every x-mcp-header annotation a server team publishes as a policy surface | Platform + AppSec |
| 7 | Validate handles against authorization context; integrity-protect requestState; set appropriate lifetimes | Platform |
| 8 | PRM + resource-indicator + iss validation; plan the DCR to CIMD move | IdP |
| 9 | Propagate traceparent / tracestate / baggage from _meta into your tracing backend | Observability |
| 10 | Keep a dual-stack window; date the shutdown of pre-7-28 endpoints | Platform |
It's a wrap
7-28 did not make MCP easier. I would almost say it made MCP ordinary; some might say finally some engineers (the good old classic ones) had their fingers on it. We got routable headers, cache directives, per-request identity, trace context, and all the same primitives you already run everything else on. That is, if you ask me, a big win.
You do not need a bespoke MCP operations discipline; you need to point the disciplines you already have at a new protocol.
Share
Author




