{"slug": "mcp-goes-stateless-with-headers-do-you-need-an-mcp-native-data-plane", "title": "MCP goes stateless with headers. Do you need an MCP-native data plane?", "summary": "The next revision of the Model Context Protocol (MCP) makes every request self-contained and independently routable by removing sessions, the initialize handshake, and server-to-client callbacks, with routing fields now carried in HTTP headers. However, platform teams are warned that a naive header-only proxy is more dangerous under the new spec, as it breaks security, authorization, federation, and identity, and an MCP-native data plane like agentgateway is still required.", "body_md": "The next revision of the Model Context Protocol (MCP) is *supposed* to make implementation infrastructure (ie, gateways, routers, etc) easier to reuse/implement. The draft (`2026-07-28`\n\n) is intended to **make every request self-contained and independently routable, and clean up anything that got in the way of that.** Sessions, the `initialize`\n\nhandshake, server-to-client callbacks, etc are all gone or deprecated.\n\nIf you expose MCP servers from a MCP/agentgateway, this is a consequential set of changes. And it’s already prompting a question I am hearing from platform teams: *if MCP is now stateless and its routing fields live in HTTP headers, can’t I just use a plain L7 load balancer or API gateway? Do I need an MCP native data plane like agentgateway?*\n\nThe premise is misunderstood. The short answer is **no!** and the new spec actually makes a naive header-only proxy **more** dangerous, not less. This post walks through what changed, why a traditional proxy breaks under it, and what an MCP-native data plane like [agentgateway](https://agentgateway.dev) does instead. We’ll even give you the most basic, runnable demo to prove it out.\n\nThree “mega-SEPs” reshape everything else, and most of the rest of the draft falls out of them.\n\n`initialize`\n\n/ `notifications/initialized`\n\nhandshake is removed. Every request now carries its own `io.modelcontextprotocol/protocolVersion`\n\n, `clientInfo`\n\n, and `clientCapabilities`\n\nin `_meta`\n\n, and a new mandatory `server/discover`\n\nRPC handles up-front capability and version negotiation. `ping`\n\n, `logging/setLevel`\n\n, and SSE resumability (`Last-Event-ID`\n\n) are gone.`Mcp-Session-Id`\n\nheader and all session-lifecycle language are removed. List endpoints (`tools/list`\n\n, etc.) no longer vary per connection, which makes them cacheable. Cross-call state moves to explicit, server-minted handles passed as ordinary tool arguments.`Mcp-Method`\n\nmirrors `method`\n\n, `Mcp-Name`\n\nmirrors `params.name`\n\n/`params.uri`\n\n, and servers can promote individual tool arguments into `Mcp-Param-*`\n\nheaders via an `x-mcp-header`\n\nannotation in the tool schema.Rounding it out: server-initiated requests (sampling, elicitation, roots callbacks) are replaced by the **Multi Round-Trip Request** pattern (SEP-2322); Tasks becomes an official extension (SEP-2663); and Roots, Sampling, and Logging are deprecated (SEP-2577).\n\nThe current *finalized* version (as of writing, one week before 7-28) is still `2025-11-25`\n\n. agentgateway already implements the draft line as protocol version `2026-07-28`\n\n, running the old and new protocols side by side, so we can talk about this concretely rather than hypothetically.\n\nHistorically, routing information was “buried deep within the JSON-RPC payload,” forcing intermediaries to “terminate TLS and perform deep packet inspection to route traffic.” Lifting `Mcp-Method`\n\nand `Mcp-Name`\n\ninto headers fixes that. And it genuinely does make coarse routing and rate-limiting easy for standard infrastructure.\n\nAnd that’s exactly where it should stop. The mistake is extrapolating from *routing* to *policy and trust*. Once you decide the header is the source of truth, four things break: security, authorization UX, federation, and identity. Let’s dig into them.\n\nHeaders and body are two copies of the same story, and nothing forces them to tell it the same way. Both are sent by the client. SEP-2243 anticipates exactly this and mandates that *“any server processing the message body MUST validate that the HTTP header values exactly match the corresponding values in the JSON-RPC body.”*\n\nBut a header-only proxy, by definition, never opens the body. Picture this: a support-desk agent is allowed to reach one MCP server through the gateway, and the platform team has locked it down to a single safe tool (`echo`\n\n) with a header allowlist. Reasonable enough. Except the same server also exposes `printEnv`\n\n. An attacker (or a prompt-injected agent) sends:\n\n```\nPOST /mcp HTTP/1.1\nMcp-Method: tools/call\nMcp-Name: echo\n\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"printEnv\",\"arguments\":{}}}\n```\n\nThe proxy sees `Mcp-Name: echo`\n\n, decides “echo is safe,” and forwards. The header said `echo`\n\n. The body ran `printEnv`\n\nand dumped its environment variables, secrets and all. This is simple header/body mismatching and it’s been the root cause of a long line of CVEs in other protocols. Relying on the server SDK to catch it is not a great plan: it’s exactly the “someone else will validate it” assumption that these bugs live in.\n\nAn MCP-native gateway parses the body and validates the headers against it. Send that spoof through agentgateway and it never reaches the backend:\n\n``` bash\n$ curl -sS http://localhost:3000/mcp \\\n -H 'Content-Type: application/json' \\\n -H 'Accept: application/json, text/event-stream' \\\n -H 'Mcp-Method: tools/call' -H 'Mcp-Name: echo' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"printEnv\",\"arguments\":{}}}'\n\n{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32020,\"message\":\"Mcp-Name header/body mismatch\"}}\n```\n\n(The full walkthrough of honest calls, method spoof, and policy denial is in [Try it yourself](https://agentgateway.dev/index.xml#try-it-yourself) at the end.)\n\nThe spoofed request is rejected with a JSON-RPC `HeaderMismatch`\n\nerror (code `-32020`\n\n, the exact allocation SEP-2243 reserves) before it ever reaches a backend. The validation runs on every Streamable HTTP POST, ahead of any routing or session logic, and the check fires whenever a header is present and disagrees with the body, regardless of which protocol version the client negotiated.\n\nSuppose you push authorization down to the header anyway. Two things go wrong immediately.\n\n**You can’t filter the tool list.** `tools/list`\n\nis a body-level response; a header-only proxy can’t rewrite it. So a user authorized for 2 of 100 tools sees all 100, and the model repeatedly tries to call tools it can’t use. That’s a miserable agent experience, and it leaks the shape of your whole tool surface to every caller.\n\nagentgateway evaluates policy against the real request and filters the list. Policies are CEL expressions that can reference the tool name, the **call arguments**, and JWT claims, not just the header-exposed name (`examples/mcp-authorization`\n\n):\n\n```\nmcp:\n policies:\n jwtAuth:\n issuer: agentgateway.dev\n audiences: [test.agentgateway.dev]\n jwks: { file: ./manifests/jwt/pub-key }\n mcpAuthorization:\n rules:\n - 'mcp.tool.name == \"echo\"' # anyone may echo\n - 'jwt.sub == \"test-user\" && mcp.tool.name == \"get-sum\"' # only test-user\n - 'mcp.tool.name == \"get-env\" && jwt.nested.key == \"value\"'\n```\n\nTools that fail policy are dropped from `tools/list`\n\n, so the model only ever sees what the caller can actually use. This is fundamentally a body-and-identity operation; there is no header that carries it.\n\n**You return the wrong kind of error.** In MCP, application-level failures are supposed to come back as JSON-RPC errors that the agent reads and routes around (“that tool isn’t available, I’ll try another path”). A naive proxy that answers a denied call with a bare HTTP `403`\n\ninstead signals a transport- or auth-level failure, and clients treat that very differently, often tearing down the connection rather than gracefully continuing. agentgateway returns a structured JSON-RPC error the agent can act on, and it deliberately *hides* the tool’s existence rather than confirming it was denied:\n\n```\n{\"jsonrpc\":\"2.0\",\"id\":4,\"error\":{\"code\":-32602,\"message\":\"Unknown tool: printEnv\"}}\n```\n\nThat distinction matters more than it looks. “Access denied” confirms the tool is there and worth attacking; “unknown tool” hands a prober nothing to work with. The higher-level modes people are building on MCP (code mode, search mode, and similar) all depend on this body-level intelligence. None of it is reachable from headers.\n\nA big feature that nearly all of our users use: “Virtual MCP” where a single MCP endpoint exposed on the gateway exposes multiple backend MCP servers. Here is where header-only routing goes from limiting to impossible. **Multiplexing** (via the Virtual MCP concept) requires rewriting the body: namespacing tool names so they don’t collide, demultiplexing a `tools/call`\n\nback to the server that owns the tool, merging `tools/list`\n\nresponses, and reconciling protocol versions across backends. agentgateway does all of this (`examples/mcp-multiplex`\n\n): tools are namespaced as `target_tool`\n\n, list operations fan out and merge, and calls are routed to the single owning backend. A header-only proxy has nowhere to put any of that logic.\n\nThe statelessness change does have a nice payoff worth calling. Under the old protocol, sessions pinned a client to a specific server instance; a proxy had to track and map session IDs to preserve that, and until proxies solved it you were effectively limited to a single replica per MCP server. agentgateway solved session mapping, but the draft makes the whole problem *disappear* for everyone: with no `Mcp-Session-Id`\n\nand self-contained requests, any request can go to any replica. Scaling an MCP backend horizontally becomes ordinary load balancing. That’s a real win, and credit goes to the MCP maintainers for designing the session problem out of existence rather than papering over it. It also frees the gateway to focus on trust, policy, and federation rather than session bookkeeping.\n\nMost of the draft’s authorization work is about clients and servers, and much of it doesn’t strictly require an MCP-native proxy. But it leans heavily on there being a capable identity layer in the path, and that’s exactly the territory agentgateway has been building out. It’s already on `main`\n\n, with tests and examples.\n\n`.well-known/oauth-protected-resource`\n\nand `oauth-authorization-server`\n\nmetadata, issues `WWW-Authenticate`\n\nchallenges, and validates JWTs across multiple issuers, with provider-specific handling for Auth0, Okta, Keycloak, and Descope.`examples/traffic-token-exchange`\n\n).`jwt-bearer`\n\n→ access token) for cross-application access, so an MCP call can carry a properly scoped, audience-bound identity to the backend (`examples/traffic-cross-app-access`\n\n).This connects directly to the draft’s smaller auth items, including issuer (`iss`\n\n) validation for multi-AS mix-up protection (SEP-2468) and the multi-authorization-server migration guidance (SEP-2352), where per-issuer JWT validation is already in place.\n\nThe draft’s thesis of self-contained, independently routable requests is the right direction, and it does make MCP easier to *move* through standard infrastructure. But moving bytes is not the same as enforcing trust. If anything, the new spec makes that boundary more important: **a header-only proxy is now not just insufficient but actively unsafe**.\n\nIf you’re working through stateless MCP behind a gateway and hitting these edges, I’d genuinely like to hear about it. Reach out on [LinkedIn](https://www.linkedin.com/in/ceposta/).\n\nThe entire setup is one small config file. It puts agentgateway in front of a local MCP server (the reference `server-everything`\n\n), runs statelessly so every request is a self-contained POST, and adds a body-based policy that allows only the `echo`\n\ntool. Save it as `agw.yaml`\n\n:\n\n```\nmcp:\n port: 3000\n statefulMode: stateless  # each request is a self-contained POST; no session handshake\n targets:\n - name: everything\n stdio:\n cmd: npx\n args: [\"-y\", \"@modelcontextprotocol/server-everything\"]\n policies:\n cors:\n allowOrigins: [\"*\"]\n allowHeaders: [\"*\"]\n mcpAuthorization:\n rules:\n - 'mcp.tool.name == \"echo\"' # body-based authz: only `echo` is permitted\n```\n\nStart the gateway (needs `npx`\n\nfor the stdio server):\n\n```\nagentgateway -f agw.yaml\n# from a source checkout: cargo run -- -f agw.yaml\n```\n\nNow drive it by hand from another terminal. Every request needs `Content-Type: application/json`\n\nand `Accept: application/json, text/event-stream`\n\n.\n\n**1. Honest call: header matches body, tool is allowlisted → forwarded**\n\n```\ncurl -sS http://localhost:3000/mcp \\\n -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\\n -H 'Mcp-Method: tools/call' -H 'Mcp-Name: echo' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"message\":\"hello\"}}}'\n# data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"Echo: hello\"}]}}\n```\n\n**2. The attack: header says echo, body calls printEnv (env exfiltration) → rejected**\n\n```\ncurl -sS http://localhost:3000/mcp \\\n -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\\n -H 'Mcp-Method: tools/call' -H 'Mcp-Name: echo' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"printEnv\",\"arguments\":{}}}'\n# {\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32020,\"message\":\"Mcp-Name header/body mismatch\"}}\n```\n\n**3. Method spoof: header tools/list, body tools/call → rejected**\n\n```\ncurl -sS http://localhost:3000/mcp \\\n -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\\n -H 'Mcp-Method: tools/list' -H 'Mcp-Name: echo' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"message\":\"x\"}}}'\n# {\"jsonrpc\":\"2.0\",\"id\":3,\"error\":{\"code\":-32020,\"message\":\"Mcp-Method header/body mismatch\"}}\n```\n\n**4. Honest but denied by the body policy: printEnv blocked, tool hidden**\n\nHeaders are consistent, but the policy allows only `echo`\n\n. Note the response says `Unknown tool`\n\nrather than “access denied”: the tool’s existence is hidden, not just its authorization.\n\n```\ncurl -sS http://localhost:3000/mcp \\\n -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\\n -H 'Mcp-Method: tools/call' -H 'Mcp-Name: printEnv' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"printEnv\",\"arguments\":{}}}'\n# {\"jsonrpc\":\"2.0\",\"id\":4,\"error\":{\"code\":-32602,\"message\":\"Unknown tool: printEnv\"}}\n```\n\n", "url": "https://wpnews.pro/news/mcp-goes-stateless-with-headers-do-you-need-an-mcp-native-data-plane", "canonical_source": "https://agentgateway.dev/blog/2026-07-21-stateless-mcp-still-needs-mcp-native-dataplane/", "published_at": "2026-07-21 00:00:00+00:00", "updated_at": "2026-07-21 13:34:04.486578+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-infrastructure", "ai-agents", "developer-tools"], "entities": ["Model Context Protocol", "agentgateway", "SEP-2243", "SEP-2322", "SEP-2663", "SEP-2577"], "alternates": {"html": "https://wpnews.pro/news/mcp-goes-stateless-with-headers-do-you-need-an-mcp-native-data-plane", "markdown": "https://wpnews.pro/news/mcp-goes-stateless-with-headers-do-you-need-an-mcp-native-data-plane.md", "text": "https://wpnews.pro/news/mcp-goes-stateless-with-headers-do-you-need-an-mcp-native-data-plane.txt", "jsonld": "https://wpnews.pro/news/mcp-goes-stateless-with-headers-do-you-need-an-mcp-native-data-plane.jsonld"}}