MCP Is Now Stateless HTTP: What That Unlocks at the Edge

·9 min read·Evergreen Tools Team

For most of its life MCP was stateful. A client sent initialize, the server returned an Mcp-Session-Id, and every later request had to carry that identifier back to the same server instance. Remote deployments pinned connections, shared session state across replicas, or ran proxies just to keep streams open. Edge inspection was impractical because the request stream was opaque. The 2026-07-28 revision removed the session entirely, and that one change is why an MCP endpoint can now be policed with the same tooling you already point at your REST APIs.

A network of connected nodes

One enforcement point, every caller, no client cooperation required

What changed on 2026-07-28

An MCP request is now a single, self-contained HTTP POST that any server instance can handle. No handshake, no session identifier, no client-to-server-instance binding. On the wire it looks like any other JSON API call, which means layer-7 inspection, transformation and optimisation at the CDN and WAF layers become available without touching MCP server code. That last clause is the important one. Security controls that require application changes get scheduled for next quarter; controls that live in front of the app ship this week.

# What an MCP request looks like after the 2026-07-28 revision.
# No handshake, no session id, no sticky routing - one POST.
POST /mcp HTTP/1.1
Host: tools.example.com
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: query_database
Authorization: Bearer <token>
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_database","arguments":{"filter":"id = 42"}}}

# Mcp-Method and Mcp-Name are mandatory on the Streamable HTTP
# transport. The server rejects any request where the headers and
# the body disagree, which is what makes the headers trustworthy
# signals for a firewall to route and block on.

The two headers worth building on

Two headers are mandatory on the Streamable HTTP transport, and they exist precisely so that load balancers, gateways and firewalls can route on the operation without parsing the body: Mcp-Method, which carries the operation such as tools/call, and Mcp-Name, which carries the tool being called. The server rejects any request where the headers and the body disagree. That rejection is what makes the headers a trustworthy signal for enforcement rather than a hint you have to double-check. For WAF purposes, the two most important facts about an MCP request are visible before a single byte of JSON is parsed.

# Allowlist the MCP methods you actually expose. Anything else is
# blocked before it reaches the origin, so the origin never spends
# a token or a database connection on it.
import json

ALLOWED_METHODS = {'tools/list', 'tools/call', 'resources/read', 'prompts/list'}
ALLOWED_TOOLS   = {'query_database', 'search_docs', 'list_schemas'}

waf_rules = [
    {
        'Name': 'mcp-method-allowlist',
        'Priority': 1,
        'Action': {'Block': {}},
        'Statement': {'NotStatement': {'Statement': {'ByteMatchStatement': {
            'FieldToMatch': {'SingleHeader': {'Name': 'mcp-method'}},
            'PositionalConstraint': 'EXACTLY',
            'SearchString': m, 'TextTransformations': [{'Priority': 0, 'Type': 'LOWERCASE'}],
        }}}},
    } for m in sorted(ALLOWED_METHODS)
]

print(json.dumps(waf_rules, indent=2)[:400])
Streams of security code on a screen

The two headers that matter are visible before the body is parsed

Nine things the new shape makes easy

AWS frames it as nine use cases the new specification makes straightforward to enforce at the edge, and the pattern generalises. Allowlist the MCP methods you expose, so unapproved operations are blocked before the origin spends a token on them. Allowlist tool names, which is the closest thing to a real capability boundary you can enforce without reading the payload. Rate limit per tool and per caller rather than per IP, so agents behind a shared NAT gateway do not share one bucket. Filter on the caller identity. Observe and log method and tool distributions. None of these require a vendor SDK or a change to the MCP server.

# Two headers give you the operation and the tool. That is enough to
# build a per-tool rate limit without parsing a single JSON body.
def tool_rate_limit(tool_name, limit_per_min=60):
    return {
        'Name': f'rl-{tool_name}',
        'Priority': 10,
        'Action': {'Block': {}},
        'Statement': {
            'RateBasedStatement': {
                'Limit': limit_per_min,
                'EvaluationWindowSec': 60,
                'AggregateKeyType': 'CUSTOM_KEYS',
                'CustomKeys': [
                    {'Header': {'Name': 'mcp-name', 'TextTransformations':
                                [{'Priority': 0, 'Type': 'LOWERCASE'}]}},
                    {'Header': {'Name': 'authorization', 'TextTransformations':
                                [{'Priority': 0, 'Type': 'NONE'}]}},
                ],
            }
        },
    }

# Rate limit per tool AND per caller, not just per IP. Agents behind
# one NAT gateway will otherwise share a single bucket.

Where to inspect if headers are not enough

Headers tell you the operation and the tool. They do not tell you the arguments. When policy needs to look at values, the target is one JSON path: $.params.arguments, where every MCP tool call places its per-tool arguments - for a database call, that is the filter string it intends to run. Everything else in the payload is protocol scaffolding, and treating it as meaningful is how you end up writing rules that break whenever the client library changes. Inspect narrowly, block on specific known-bad values, and log the rest rather than blocking on heuristics you cannot explain a week later.

# When you need deeper inspection, the target is one JSON path.
# Every MCP tool call puts its per-tool arguments at $.params.arguments.
import json

def inspect_arguments(body: dict, banned_values):
    args = body.get('params', {}).get('arguments', {})
    hits = [v for v in args.values() if isinstance(v, str) and v in banned_values]
    return {'tool': body.get('params', {}).get('name'),
            'arg_keys': sorted(args),
            'blocked_values': hits,
            'decision': 'block' if hits else 'allow'}

# { "params": { "name": "query_database",
#               "arguments": { "filter": "id = 42" } } }
# Everything outside $.params.arguments is protocol scaffolding.
A padlock and encryption concept

Default deny at the edge, explicitly allowed methods at the origin

What edge enforcement cannot do

WAF cannot tell whether a permitted tool call was a good idea. A prompt injection that convinces an agent to call a tool it legitimately has access to looks exactly like a normal request, because it is one. Edge rules shrink the surface and give you a place to observe; they do not make authorisation decisions about intent. The enforcement point is also only as good as its deployment - a web ACL that is not associated with the distribution, or a rule sitting at the wrong priority behind a terminating rule, enforces nothing at all, and it fails silently, which is worse than failing loudly.

# Prove the rule is attached and firing. An unenforced rule is a
# comment with a JSON accent.
#!/usr/bin/env bash
set -euo pipefail
ENDPOINT="https://tools.example.com/mcp"

code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$ENDPOINT" \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: drop_everything' \
  -H 'Authorization: Bearer '"$TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"drop_everything","arguments":{}}}')

[ "$code" = "403" ] || { echo "expected 403 from WAF, got $code"; exit 1; }
echo 'WAF is enforcing the MCP tool allowlist'

Making it real

Associate a WAF web ACL with the CloudFront distribution or ALB, put the allowlist rules first, and prove they work by firing a deliberately disallowed tool name at the endpoint and asserting a 403 before the origin sees it. Ship that check into CI, because a security boundary nobody tests is a security claim. Watch the block metrics for a week before tightening; a first-day block spike usually means a client you forgot to register rather than an attack. And keep the enforcement point the one you fully control, in front of every caller, regardless of which framework or network path they arrive on.

📌 Frequently Asked Questions

What changed in MCP on 2026-07-28?

MCP moved from a stateful, session-pinned protocol to a stateless HTTP request/response design. Every MCP request is now a self-contained POST that any server instance can handle, with no initialize handshake and no Mcp-Session-Id to pin routing.

Which headers should edge rules use?

Mcp-Method, which carries the operation such as tools/call, and Mcp-Name, which carries the tool name. Both are mandatory on the Streamable HTTP transport, and the server rejects requests where the headers and body disagree, so a firewall can route and block on them without parsing the body.

Where do the arguments live in the payload?

At $.params.arguments. Every MCP tool call places its per-tool arguments there. Everything else in the JSON is protocol scaffolding, so inspect that path narrowly rather than writing broad body rules that break when client libraries change.

Do I need to change my MCP server?

No. AWS's guidance is that WAF and CloudFront enforce this in front of the endpoint, the same way you would front any REST API, with no changes to MCP server code. You need a web ACL associated with the distribution or ALB, and the rules ordered correctly.

How do I know the rule is actually working?

Send a request using a tool name that is not on your allowlist and assert that the response is a 403 from the WAF before it reaches the origin. Put that check in CI. An unassociated web ACL or a rule masked by an earlier terminating rule enforces nothing, and it fails silently.