MCP 变成无状态 HTTP 之后,边缘上终于可以管它了
MCP 在大部分生命周期里都是有状态的。客户端发 initialize,服务端返回一个 Mcp-Session-Id,之后每个请求都要带着这个标识回到同一个服务实例。远程部署因此需要固定连接、在副本间共享会话状态,或者干脆架一层代理把流一直挂着。边缘检查根本不现实,因为请求流是不透明的。2026-07-28 版修订把会话整个去掉了,而正是这一处改动,让 MCP 端点可以用你早就对着 REST API 用的那套工具来管。
一个执行点,覆盖所有调用方,不需要客户端配合
2026-07-28 改了什么
现在一次 MCP 请求就是一个自包含的 HTTP POST,任何一个服务实例都能处理。没有握手、没有会话标识、没有客户端到实例的绑定。在线上它看起来就跟其它 JSON API 调用一样,于是 CDN 与 WAF 层的七层检查、变换与优化就可以用了,而且不用改 MCP 服务端代码。最后这一句最关键。需要改应用的安全控制,会被排到下个季度;摆在应用前面的控制,这周就能上线。
# 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.
值得押注的两个请求头
Streamable HTTP 传输上有两个强制头,它们存在的意义正是让负载均衡器、网关和防火墙不必解析请求体就能按操作做路由:Mcp-Method 携带操作(例如 tools/call),Mcp-Name 携带被调用的工具名。服务端会拒绝头与体不一致的任何请求。正是这个「拒绝」让这两个头成为可用于强制执行的可信信号,而不是一个你还得复核的提示。对 WAF 来说,一次 MCP 请求最关键的两个事实,在解析任何一个字节的 JSON 之前就已经可见了。
# 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])
关键的两个头在解析请求体之前就可见了
新形态让九件事变简单
AWS 把它描述成新规范让「在边缘轻松执行」的九个用例,而规律是可以泛化的。给你的 MCP 方法列白名单,未批准的操作在源站消耗一个 token 之前就被拦掉;给工具名列白名单,这是你不读负载就能执行的、最接近真实能力边界的做法;按工具和调用方限流而不是按 IP,这样躲在同一个 NAT 网关后面的 Agent 不会共用一个桶;按调用方身份做过滤;观测并记录方法与工具的分布。这些都不需要厂商 SDK,也不需要改 MCP 服务端。
# 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.
如果头不够用,该检查哪里
头告诉你「操作」和「工具」,但不告诉你「参数」。当策略需要看具体值时,目标是唯一一个 JSON 路径:$.params.arguments——每一次 MCP 工具调用都把自己的参数放在那里;对数据库调用来说,就是它准备执行的那个 filter 字符串。负载里其余一切都是协议脚手架,把它当成有含义的东西,你就会写出「客户端库一升级就崩」的规则。应该窄范围检查、对明确已知的坏值做阻断,其余只记录,而不是基于「一周后你自己都解释不清的启发式」去阻断。
# 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.
边缘默认拒绝,源站只放行显式允许的方法
边缘执行做不到什么
WAF 无法判断一次被允许的工具调用「该不该调用」。一次说服 Agent 去调用它本来就有权限调用的工具的提示词注入,看起来跟正常请求一模一样——因为它本来就是。边缘规则缩小了攻击面、给了你可观测的地方,但它不对「意图」做授权判断。而且执行点的效果完全取决于部署是否正确:一个没有关联到分发的 web ACL,或者一条被前面终止性规则遮住的规则,实际上什么都拦不住,而且是静默失败——这比大声失败更糟。
# 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'
把它真正落地
把 WAF web ACL 关联到 CloudFront 分发或 ALB,把白名单规则放在最前面,然后拿一个刻意不在白名单里的工具名去打端点,断言源站看到之前先收到 403。把这条检查放进 CI,因为没人测试的安全边界只是一个安全声明。收紧之前先看一周的阻断指标;第一天的阻断尖峰,通常意味着你漏登记了一个客户端,而不是有人在攻击。并且把执行点保持在那个「你完全可控、位于所有调用方之前、不管你用什么框架、走什么网络路径都要经过」的位置。
📌 常见问题 FAQ
2026-07-28 的 MCP 修订改了什么?
MCP 从有状态、绑定会话的协议改成了无状态的 HTTP 请求/响应设计。现在每次 MCP 请求都是自包含的 POST,任何服务实例都能处理,没有 initialize 握手,也没有用来固定路由的 Mcp-Session-Id。
边缘规则该用哪些请求头?
Mcp-Method 携带操作(例如 tools/call),Mcp-Name 携带工具名。两者在 Streamable HTTP 传输上都是强制的,且服务端会拒绝头与体不一致的请求,所以防火墙可以不解析请求体就基于它们做路由与阻断。
参数在负载的什么位置?
在 $.params.arguments。每一次 MCP 工具调用都把自己的参数放在那里。JSON 里其余都是协议脚手架,因此应只窄范围检查该路径,而不是写宽泛的请求体规则——后者在客户端库变更时会失效。
需要改我的 MCP 服务端吗?
不需要。AWS 的指引是让 WAF 与 CloudFront 在端点前面执行,就像你给任何 REST API 加一层那样,无需改动 MCP 服务端代码。你需要的是一个与分发或 ALB 关联的 web ACL,以及正确的规则顺序。
怎么确认规则真的生效了?
用一个不在白名单里的工具名发请求,断言在到达源站之前先收到来自 WAF 的 403。把这条检查放进 CI。未关联的 web ACL、或被更早的终止性规则遮住的规则,实际上什么都拦不住,而且会静默失败。