What the new spec put on your plate
The Model Context Protocol specification version 2026-07-28 was published as final on 28 July 2026. It is the version most teams will standardise on for the rest of the year, and the important thing about it is not the feature list. The important thing is the redistribution of responsibility: the overhaul shifts critical security responsibilities from the protocol itself onto developers and platform operators. SecurityWeek characterised the new enterprise-ready specification as bringing new security challenges, which is a polite way of saying that a protocol which is now good enough for enterprises is also now interesting enough for people who attack enterprises.
Concretely, the specification introduces MCP-specific HTTP headers, and those headers bring two named new risks: protocol confusion, commonly known as desync, and data leakage via the x-mcp-header. Both of those live entirely in the transport. Neither is fixed by bumping a dependency. Both are fixed, or not fixed, by how you have configured the gateway, the reverse proxy and the CDN sitting between an agent and your tools.
- MCP is now plain HTTP in production, which means it inherits request smuggling, header injection, cache poisoning and every other transport-layer defect the web has been fighting for two decades.
- The spec assumes an operator. If nobody on your team owns the proxy configuration, nobody owns MCP security.
- Two named risks are your starting checklist: desync from parser disagreement, and header leakage from over-permissive forwarding, logging and caching.
- Exposure is the unglamorous majority of the problem. Trend Micro found 492 MCP servers exposed to the internet with zero authentication.
- Regulators have started writing this down. The US National Security Agency published MCP security guidance in 2026, a Cybersecurity Information Sheet covering Model Context Protocol and security design considerations for AI-driven automation.
This article is deliberately narrow. It is about how MCP talks, not about what you install. Choosing which servers to trust, checking provenance and pinning versions is a separate discipline, and it is covered in the companion piece on vetting MCP servers and agent skills for supply-chain risk. Everything below assumes you have already decided the server is one you want to run, and asks only how it should be reachable.
Ordinary HTTP problems, now in your agent stack
There is a widely used framing in 2026 that rapid innovation in MCP has outpaced its own security and architectural guardrails. That is a fair description of what happened. MCP went from a developer convenience to a load-bearing production integration in roughly eighteen months, and the deployment patterns that came with it were copied from tutorials rather than derived from threat models. A very large number of production MCP endpoints are a framework's default HTTP server, wrapped in whatever ingress the platform team already had, exposed on a hostname that was chosen for convenience.
The move to HTTP is genuinely the right call. It is what makes MCP deployable behind a load balancer, scalable across replicas, observable with tools you already run, and compatible with the authentication stack your organisation already audits. It is also what makes MCP subject to the accumulated pathology of HTTP intermediaries. Every one of the following is now in scope for an MCP deployment in a way it simply was not when the transport was a local pipe to a subprocess on the developer's laptop.
| Risk | What it looks like in MCP | Primary mitigation | Enforced at |
|---|---|---|---|
| Protocol confusion / desync | Two hops disagree on request boundaries; an attacker's bytes are read as the next caller's request | Single normalising parser; reject ambiguous framing; terminate and re-issue rather than forward raw | Gateway |
Header leakage via x-mcp-header |
Session or routing context echoed into logs, error bodies, CORS reflections or cached responses | Strict allowlist per hop; redact in logging; never echo request headers in error output | Gateway and server |
| Identity spoofing on the transport | Client sets a header claiming to be a privileged user or tenant; the server believes it | Strip all identity headers at the edge, then re-add from verified token claims | Gateway |
| Session fixation and reuse | A session identifier accepted from any caller, on any connection, from any address | Bind the session to the authenticated principal; rotate on privilege change | Server |
| Cache cross-contamination | One tenant's tool output served to another because the cache key ignored the varying header | Default to no-store; explicit Vary on any deliberate exception |
CDN |
| Unauthenticated exposure | Endpoint answers protocol requests to anyone who finds the hostname | Deny by default at ingress; authenticate before protocol negotiation | Ingress and gateway |
The column that matters most is the last one. Almost every mitigation here belongs somewhere other than the MCP server's own code, which is exactly why these defects survive code review. A platform team in Chennai running twenty internal MCP servers behind a shared ingress fixes all twenty of these at once by changing one gateway configuration, or leaves all twenty broken by not touching it. The unit of remediation is the edge, not the service.
If your MCP server is reachable on an internal hostname that resolves publicly, "internal" is a naming convention rather than a control. Verify reachability from outside your network, from a residential connection, not from a bastion host that already sits inside the perimeter. As of August 2026 the fastest way to find an exposed MCP endpoint is still a search engine index of hostnames, not a clever exploit.
Protocol confusion and desync in an MCP proxy chain
Request smuggling, also called desync, is a well-established class of web security defect. The mechanism is simple to state. HTTP allows more than one way to describe how long a request body is, and it allows intermediaries to reuse a single TCP or TLS connection for many requests in sequence. If two hops in a chain compute the boundary between one request and the next differently, then bytes that the first hop thinks are part of request A are read by the second hop as the beginning of request B. An attacker who controls those bytes controls the front of somebody else's request.
Why an MCP chain creates the disagreement
A typical production MCP path in August 2026 has at least four parsers in it: the agent client, an LLM or MCP gateway that handles routing and authentication, a CDN or edge proxy, and finally the MCP server's own HTTP stack. Those four are usually four different codebases written in three different languages with independent interpretations of the more ambiguous corners of the HTTP specification. That is the precondition for desync, and it exists in almost every MCP deployment by construction rather than by accident.
The new MCP-specific headers make this materially more interesting, because they give the parsers something new to disagree about. A header that one hop treats as a routing directive and another treats as opaque pass-through data is a protocol confusion waiting to happen. The consequence in an MCP context is worse than the generic web case: the thing an attacker prepends to another caller's request is a tool invocation, executed with that caller's credentials, against systems the attacker cannot otherwise reach. If your MCP server fronts a payments API or a customer database, a desync is not an information leak, it is a remote action.
What actually stops it
Four mitigations do the real work, and they are all about eliminating the parser disagreement rather than detecting its exploitation.
- One normalising parser at the front. Put a single hardened proxy at the edge whose parse of the request is definitive. It should rewrite the request into an unambiguous canonical form before anything downstream sees it, so that downstream parsers have nothing left to disagree about.
- Reject ambiguous framing outright. A request that specifies its length in two contradictory ways is not a request you need to serve. Return a 400 and close the connection. There is no legitimate MCP client that depends on ambiguous framing, so the false-positive cost is zero.
- Terminate and re-issue rather than forward raw. The gateway should fully read, validate and then construct a fresh upstream request from parsed values. Forwarding the original byte stream is what carries the ambiguity forward; rebuilding the request destroys it.
- Disable connection reuse and pipelining where it is not earning its keep. Desync needs a shared connection to poison. If your gateway-to-server hop is inside a single trusted network segment and latency is not critical, disabling upstream keep-alive removes the attack entirely at a modest throughput cost. Measure before you decide, but be honest that a persistent connection between two hops that parse differently is the vulnerability.
# nginx: normalise at the edge, reject ambiguity, rebuild upstream
server {
listen 443 ssl http2;
server_name mcp.example.in;
# Reject anything with an underscore or otherwise non-conforming header name
underscores_in_headers off;
ignore_invalid_headers on;
# Do not let a large or chunk-abusing body sit half-parsed
client_max_body_size 2m;
client_body_buffer_size 128k;
client_body_timeout 10s;
location /mcp {
# Rebuild the upstream request rather than forwarding the raw stream
proxy_http_version 1.1;
proxy_request_buffering on;
# Kill inbound hop-by-hop and identity headers before they travel
proxy_set_header Connection "";
proxy_set_header Transfer-Encoding "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Client-supplied identity is never trusted; re-added from verified claims
proxy_set_header X-Mcp-Tenant $jwt_tenant_id;
proxy_set_header X-Mcp-Principal $jwt_subject;
proxy_pass http://mcp_upstream;
}
}
upstream mcp_upstream {
server 10.0.4.11:8080;
# No keepalive directive: each upstream request gets its own connection.
# Costs throughput, removes the shared connection a desync needs to poison.
}
The transparent pass-through gateway. It is the single most common MCP deployment shape and the worst one: an ingress that forwards the raw request bytes untouched "so the server sees exactly what the client sent". That property is precisely what you do not want. If the gateway does not re-parse and rebuild, it is not a security boundary, it is a longer wire with a TLS certificate on it.
Header hygiene and the x-mcp-header leak
The second named risk in the new specification is data leakage via the x-mcp-header. It is worth being precise about the failure mode, because it is not that MCP mishandles the header. MCP handles it fine. Everything adjacent to MCP mishandles it.
There are five well-worn paths by which a header that was meant to travel one hop ends up somewhere it should never be. The first is proxy forwarding: most reverse proxies pass inbound headers upstream by default, so a header intended for the gateway arrives at a third-party MCP server you do not control. The second is logging: access logs and tracing spans commonly capture full request headers, which means the header is now in your observability stack, your log-shipping pipeline, your vendor's storage and your backups. The third is error pages: debug output and verbose 500 handlers routinely echo the request back, and error pages get screenshotted into support tickets. The fourth is CORS: a configuration that reflects arbitrary values from Access-Control-Request-Headers into Access-Control-Allow-Headers tells a browser-based attacker exactly which headers your deployment understands. The fifth is caching, which is serious enough that it gets its own section below.
The fix in every case is the same discipline, applied per hop: an explicit allowlist in each direction, with everything else dropped. Not a denylist. A denylist is a promise that you have enumerated every header that could ever be sensitive, which nobody has ever managed to do.
| Header class | Direction | Action at the gateway | Why |
|---|---|---|---|
MCP protocol headers including x-mcp-header |
Client to gateway | Allowlist, validate, consume | Meaningful to the gateway; must not travel further by default |
| MCP protocol headers | Gateway to upstream | Re-emit only what the upstream needs | A third-party server has no business seeing your routing context |
| Identity and tenant headers | Client to gateway | Strip unconditionally | Client-supplied identity is an assertion, not a fact |
| Identity and tenant headers | Gateway to upstream | Set from verified token claims | Only the gateway has verified the token, so only it may assert identity |
Authorization and cookies |
Gateway to third-party upstream | Strip; exchange for a scoped upstream credential | Never forward a credential minted for one audience to another |
| Any header in an error body | Server to client | Never echo | Error output is the most widely shared surface you have |
| Unknown or unrecognised headers | Any | Drop silently | Default-deny is the only rule that survives a spec revision |
// Gateway header allowlist — applied on ingress and again before upstream dispatch
const INBOUND_ALLOW = new Set([
"content-type",
"content-length",
"accept",
"authorization", // verified here, then discarded
"mcp-protocol-version",
"mcp-session-id",
]);
// Headers we are willing to emit toward an upstream MCP server
const UPSTREAM_ALLOW = new Set([
"content-type",
"content-length",
"accept",
"mcp-protocol-version",
]);
const REDACT_IN_LOGS = /^(authorization|cookie|x-mcp-|mcp-session)/i;
function sanitiseInbound(raw) {
const clean = new Headers();
for (const [k, v] of raw.entries()) {
if (INBOUND_ALLOW.has(k.toLowerCase())) clean.set(k, v);
// everything else is dropped, including x-mcp-header, which the
// gateway reads separately and never forwards verbatim
}
return clean;
}
function buildUpstream(headers, claims) {
const out = new Headers();
for (const [k, v] of headers.entries()) {
if (UPSTREAM_ALLOW.has(k.toLowerCase())) out.set(k, v);
}
// Identity is asserted only by us, only from verified claims
out.set("x-mcp-principal", claims.sub);
out.set("x-mcp-tenant", claims.tenant_id);
return out;
}
function logSafe(headers) {
const o = {};
for (const [k, v] of headers.entries()) {
o[k] = REDACT_IN_LOGS.test(k) ? "[redacted]" : v;
}
return o;
}
Apply the redaction at the point where the log line is constructed, not in a downstream log-processing rule. A redaction rule in your observability vendor's pipeline fixes what you can see in the dashboard and does nothing about the raw payload that already crossed a network boundary and landed in object storage. For a London firm under ICO scrutiny, the difference between those two designs is the difference between a control and a screenshot of a control.
Sessions, auth and who the caller actually is
MCP sessions are long-lived by design, which is what makes them useful and what makes them worth stealing. A session that has completed authentication, negotiated capabilities and accumulated tool permissions is a far richer target than a single request. The 2026-07-28 specification's move toward stateless operation changes the shape of this problem without removing it, and the mechanics of that migration are worth reading separately in the guide to migrating an MCP server to the stateless model alongside our news coverage of what the final 2026-07-28 specification actually changed.
Three rules cover most of the transport-layer session risk. First, bind the session to the authenticated principal at creation and check that binding on every subsequent request. A session identifier that any caller can present, from any address, on any connection, is a bearer token with none of the protections you would give a bearer token. Second, never derive identity from a client-supplied header. This sounds obvious and is violated constantly, because a header is such a convenient way to pass a tenant identifier between internal services and nobody remembers that the service became internet-reachable in the meantime. Third, reject header spoofing at the edge rather than in the application, because the application cannot tell the difference between a header your gateway set and a header the caller sent.
Authorisation on the transport is not a substitute for authorisation at the tool boundary. The gateway can tell you which principal is calling; it cannot tell you whether that principal should be allowed to invoke a particular tool with particular arguments. Both layers are needed, and the credential each tool receives should be scoped to exactly what that tool does, which is the argument made in more depth in the piece on least-privilege credentials for AI agents. The NSA guidance published in 2026 makes essentially the same point in the language of security design considerations for AI-driven automation: the automation is only as contained as the credentials you hand it.
Every article here is written by a Verified Builder. Want your name on the next one?
AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.
Become a Verified Builder →Caching and CDN behaviour in front of MCP
A naive cache in front of an MCP server is a data-leak engine, and it is the failure that produces the worst incident reports because the leaked data goes to a real user rather than to an attacker who had to work for it. The reason is a mismatch of assumptions. A CDN's default cache key is built for a content site: method, host, path, perhaps query string. An MCP response is specific to a session, a tenant, a set of negotiated capabilities and often a point in time. Cache one with the other's key and you will eventually return one customer's tool output to another customer.
The correct default is no caching at all. MCP traffic is not what a CDN is for; you are using the CDN for TLS termination, for DDoS absorption, for regional presence in Mumbai and London, and none of those require a cache. Turn the cache off on the MCP route and turn it on only for a route you can positively argue is public and session-independent — a static capability manifest, perhaps, and not much else. Where you do cache, declare Vary on every header that changes the response, and understand that a missing Vary is not a performance bug, it is a cross-tenant disclosure.
# Cache posture for an MCP route — default deny, narrow exception
# 1. Protocol traffic: never store, never share, never revalidate from a shared cache
location /mcp {
add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
add_header Pragma "no-cache" always;
add_header Vary "Authorization, Mcp-Session-Id, Mcp-Protocol-Version" always;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 2. A genuinely public, session-independent manifest is the only sane exception
location = /mcp/manifest.json {
add_header Cache-Control "public, max-age=300" always;
add_header Vary "Accept-Encoding, Mcp-Protocol-Version" always;
proxy_cache mcp_manifest_zone;
proxy_cache_key "$scheme$host$request_uri$http_mcp_protocol_version";
}
# 3. Never let an error response be cached — error bodies leak the most
proxy_cache_valid 400 401 403 404 500 502 503 504 0;
The same reasoning applies to your own application-level caches and to any response memoisation inside the gateway. Wherever a response is stored and later reused, ask what would have to be in the key for reuse to be safe, and then check that it actually is. Gateways that already handle failover and retries are a common place for an accidental cache to appear; the design considerations for that layer are covered in the guide to building a resilient LLM gateway, and the caching rules there are the same rules as here.
Testing what you have actually deployed
Configuration you have not probed is configuration you are hoping about. The tests below are deliberately crude, because crude tests are the ones that get run. All of them should be executed against every hostname you serve MCP on, in every region, including the staging environment somebody stood up in AWS Mumbai for a demo in March and never decommissioned.
The three probes that matter
The exposure probe asks your endpoint a protocol question with no credentials at all, from an address that is not on any allowlist. If you get a protocol response rather than a 401, you are one of the 492. Trend Micro's figure is a useful reality check on how sophisticated this problem is: the majority of exposed MCP servers were not defeated, they were simply never defended.
The header-echo probe sends a uniquely identifiable value in a header and then searches every response body, every error page, every CORS preflight response and every log line for that value. If your marker comes back anywhere it should not, you have found a leak path before an attacker did. Run it against error routes specifically, because that is where echoing is most common and least noticed.
The desync probe sends requests with deliberately ambiguous framing and observes whether the endpoint rejects them. You are not trying to exploit anything; you are checking that ambiguity produces a 400 and a closed connection rather than a 200. A hardened edge fails every one of these requests. Anything that succeeds is a parser that has made a decision you did not review.
#!/usr/bin/env bash
# mcp-transport-probe.sh — run from outside your network, per hostname
set -uo pipefail
HOST="${1:?usage: mcp-transport-probe.sh https://mcp.example.in}"
MARK="atc-probe-$(date +%s)"
FAIL=0
echo "== 1. Unauthenticated exposure =="
CODE=$(curl -s -o /tmp/mcp-body.txt -w '%{http_code}' \
-X POST "$HOST/mcp" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
echo " PASS unauthenticated request rejected ($CODE)"
else
echo " FAIL endpoint answered $CODE without credentials"; FAIL=1
fi
echo "== 2. Header echo =="
curl -s -D /tmp/mcp-hdr.txt -o /tmp/mcp-body2.txt \
-X POST "$HOST/mcp" \
-H "x-mcp-header: $MARK" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"nonexistent/method"}' >/dev/null
if grep -qi "$MARK" /tmp/mcp-hdr.txt /tmp/mcp-body2.txt; then
echo " FAIL marker reflected in response or headers"; FAIL=1
else
echo " PASS marker not reflected"
fi
echo "== 3. CORS reflection =="
curl -s -D - -o /dev/null -X OPTIONS "$HOST/mcp" \
-H 'Origin: https://evil.example' \
-H "Access-Control-Request-Headers: $MARK" \
| grep -i 'access-control-allow-headers' | grep -qi "$MARK" \
&& { echo " FAIL arbitrary request headers reflected"; FAIL=1; } \
|| echo " PASS no arbitrary header reflection"
echo "== 4. Ambiguous framing (must be rejected) =="
for FRAME in "dual-length" "bad-chunk"; do
if [ "$FRAME" = "dual-length" ]; then
RESP=$(printf 'POST /mcp HTTP/1.1\r\nHost: %s\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n' \
"${HOST#https://}" | timeout 5 openssl s_client -quiet -connect "${HOST#https://}:443" 2>/dev/null | head -1)
else
RESP=$(printf 'POST /mcp HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n\r\n' \
"${HOST#https://}" | timeout 5 openssl s_client -quiet -connect "${HOST#https://}:443" 2>/dev/null | head -1)
fi
case "$RESP" in
*400*|*"") echo " PASS $FRAME rejected" ;;
*) echo " FAIL $FRAME accepted: $RESP"; FAIL=1 ;;
esac
done
exit "$FAIL"
Wire this into CI and run it on a schedule against production, not only at deploy time. Transport posture drifts: somebody adds a CDN rule, somebody enables a debug handler to chase a bug and forgets to remove it, somebody promotes a new gateway version whose defaults differ. Scheduled probing is what turns a one-off hardening exercise into a property of the system. If you want a broader sense of how adversaries actually go after agent authorisation once they are past the transport, our coverage of AgentRedBench and its 215 agent authorisation attacks is a useful companion read.
The hardening checklist
Copy this into your runbook. As of August 2026 it is the set of transport-layer controls I would expect to find on any MCP deployment that handles data worth protecting, whether it sits in AWS Mumbai serving a Chennai platform team or in London serving a firm that answers to the FCA.
- Deny by default at ingress. No MCP route answers a request that has not authenticated. Verify from outside the network, not from a bastion.
- One normalising parser at the edge. The gateway's parse is definitive; downstream hops receive a canonical request.
- Reject ambiguous framing. Contradictory length signals get a 400 and a closed connection, always.
- Terminate and re-issue. Never forward the raw byte stream upstream. Rebuild the request from parsed values.
- Reconsider upstream keep-alive. Where latency permits, remove the shared connection that desync depends on.
- Allowlist headers in both directions. Inbound at the gateway, outbound to each upstream. Drop everything unrecognised.
- Strip client-supplied identity headers unconditionally, then set them yourself from verified token claims.
- Redact at log construction.
Authorization, cookies, session identifiers and everyx-mcp-header never reach the log line in cleartext. - Never echo request headers in error output. Generic error bodies, detail in server-side traces only.
- Pin CORS to an explicit allowlist. No reflection of arbitrary origins or request headers.
- Default to
no-storeon every MCP route. Cache only what you can argue is public, and declareVarywhen you do. - Never cache error responses, at the CDN or in the gateway.
- Bind sessions to the authenticated principal and rotate the identifier on any privilege change.
- Scope upstream credentials per tool. Transport authentication is not tool authorisation.
- Run the probe script on a schedule against every hostname and region, including staging, and alert on any failure.
None of this is exotic. It is the accumulated hygiene of two decades of web operations, applied to a protocol that arrived in production faster than that hygiene travelled with it. The specification published on 28 July 2026 made the division of labour explicit: the protocol does its part, and the operator does the rest. The list above is the rest.