远程 MCP:Streamable HTTP 绑定、无状态模型与请求元数据、OAuth 2.1 授权、容器部署
把服务端从本机子进程搬到公网:一个 POST 端点、两种响应形态、一套必填请求头,再配上 OAuth 2.1 的受众校验与一份能横向扩容的容器部署。
今日目标
- 能实现一个只有单个 POST 端点的 Streamable HTTP 服务端,并按需返回 JSON 或事件流
- 能说出这一版删掉了会话、GET 流与断流续传之后,客户端要怎么改
- 能解释 MCP 服务端作为受保护资源必须做的令牌受众校验,以及不做会怎样
前三天你的服务端都住在本机,被宿主当子进程拉起来,凭证从环境变量取,没人能从外面碰它。今天把它搬到公网——这一步会把「谁能连」「连上之后算谁的」「挂了怎么办」三个问题一次性推到你面前。读完回来把上面三条勾掉。
小白版讲解
从屋里现拉的线到接市政电网
stdio 是你在屋里现拉的一根线:两头都在你家,插头什么形状你自己说了算,没人会来蹭电。Streamable HTTP 是接市政电网:电压、频率、插座标准全得按公用规范来,因为线路那头是谁你不知道。
多出来的问题有四类,每一类都有对应的硬要求。
第一类是「谁在敲门」。 浏览器里的任意一个网页都能往 http://127.0.0.1:3034 发请求,这就是 DNS 重绑定攻击的入口:攻击者控制一个域名,让它先解析到自己的服务器骗过同源检查,再改解析到 127.0.0.1,于是你本机那个能读文件的 MCP 服务端就被一个陌生网页接管了。规范对此的要求是硬的:服务端必须校验 Origin 请求头,存在且非法时必须回 403。本机运行时还应该只绑回环地址 127.0.0.1,而不是 0.0.0.0——很多人在容器里习惯性写 0.0.0.0,然后把这个习惯带回了裸机。
第二类是「算谁的」。 stdio 下服务端天然只服务一个用户,公网上一个端点要面对成千上万个。规范说得很直白:所有连接都应该实现认证。具体做法在后面的 OAuth 那一节。
第三类是「有几个我」。 本机永远只有一个进程;公网上你会起三个五个副本,请求打到哪个都得能处理。这一条恰好是无状态协议最大的红利,最后一节会算这笔账。
第四类是「什么时候算完」。 本机调用几毫秒就回来了,公网上一个工具跑三十秒是常态,这期间连接空着,用户分不清它是在算还是已经死了。
第一类靠一行校验解决,剩下三类都得从传输层的形状说起。那这个端点具体长什么样?
一个端点两种回法
结论比很多人想的简单:服务端必须提供单独一个支持 POST 的 HTTP 路径,比如 https://example.com/mcp。就这一个。没有第二个路径,没有 WebSocket,没有轮询端点。
客户端这边的规矩也短:每一条 JSON-RPC 消息都必须是一个新的 HTTP POST;Accept 头里必须同时列出 application/json 和 text/event-stream;请求体必须是单条 JSON-RPC 请求或通知,不能是 JSON-RPC 响应。
服务端收到后分两种情况。请求体是通知(没有 id)时,接受就回 202 Accepted 且不带 body。是请求时,服务端自己决定回哪种:
Content-Type: application/json——一个 JSON 对象,完事。Content-Type: text/event-stream——一条只属于这次请求的事件流,先流出若干条与本请求相关的通知,最后流出正式响应。
客户端两种都必须支持,因为选择权在服务端。这是最容易漏的一条:很多自己写的客户端只处理了 application/json,接上一个会流式回进度的服务端就直接崩了。
事件流上有三条硬规矩。流上可以发 notifications/progress 和 notifications/message,但它们必须与发起这条流的请求相关;服务端绝不能在这条流上发独立的 JSON-RPC 请求(上一版这么干,本版明令禁止,理由下一节说);最终响应应当终止这条流。两条工程建议也别省:开流时带上 X-Accel-Buffering: no,否则 nginx 之类的反向代理会把事件攒着一起发,本机测一切正常、上了线进度全糊在最后一秒;长活的流要周期性发一行以冒号开头的 SSE 注释做保活,免得被中间层按空闲超时掐断。
还有一条容易忽略的语义:客户端关闭事件流,就是这次请求的取消信号。因为每条请求有自己独立的响应流,断开是没有歧义的,所以 HTTP 上根本不需要 notifications/cancelled 这条消息(它只在 stdio 上用)。
// 一个端点,两种回法。真正的分支只有这一处
async function handlePost(req, res, msg) {
if (msg.id === undefined) {
res.writeHead(202) // 通知:接受了就回 202,没有 body
return res.end()
}
const tool = TOOLS.get(msg.params.name)
if (!tool.streaming) {
const result = await tool.run(msg.params.arguments)
return sendJson(res, 200, { jsonrpc: '2.0', id: msg.id, result })
}
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'x-accel-buffering': 'no', // 不加这行,nginx 会把进度攒着一起发
})
res.write(':\n\n') // 冒号开头是 SSE 注释行,用作保活,客户端必须忽略
const send = (payload) => res.write(`data: ${JSON.stringify(payload)}\n\n`)
const token = msg.params._meta?.progressToken
const result = await tool.run(msg.params.arguments, {
// 客户端没给 progressToken 就一条进度都不许发
progress: token ? (u) => send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, ...u } }) : undefined,
})
send({ jsonrpc: '2.0', id: msg.id, result })
res.end() // 最终响应终止这条流
}# 一个端点,两种回法。真正的分支只有这一处
async def handle_post(request):
msg = await request.json()
if "id" not in msg:
return Response(status_code=202) # 通知:接受了就回 202,没有 body
tool = TOOLS[msg["params"]["name"]]
if not tool.streaming:
result = await tool.run(msg["params"]["arguments"])
return JSONResponse({"jsonrpc": "2.0", "id": msg["id"], "result": result})
async def event_stream():
yield ":\n\n" # 冒号开头是 SSE 注释行,用作保活,客户端必须忽略
queue: asyncio.Queue = asyncio.Queue()
token = msg["params"].get("_meta", {}).get("progressToken")
def on_progress(update): # 客户端没给 progressToken 就一条进度都不许发
if token is not None:
queue.put_nowait({"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"progressToken": token, **update}})
task = asyncio.create_task(tool.run(msg["params"]["arguments"], progress=on_progress))
while not task.done() or not queue.empty():
with contextlib.suppress(asyncio.TimeoutError):
yield f"data: {json.dumps(await asyncio.wait_for(queue.get(), 0.1))}\n\n"
yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': msg['id'], 'result': task.result()})}\n\n"
# 不加 X-Accel-Buffering,nginx 会把事件攒着一起发
return StreamingResponse(event_stream(), media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"})必填请求头:协议版本、方法名、目标名
这一版给 Streamable HTTP 加了一套必填请求头,它们把请求体里的几个关键字段镜像到 HTTP 头上。
| 头 | 取自请求体的 | 什么时候必填 |
|---|---|---|
MCP-Protocol-Version | _meta 里的协议版本 | 每个 POST |
Mcp-Method | method | 每个 POST |
Mcp-Name | params.name 或 params.uri | tools/call、resources/read、prompts/get |
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather为什么要抄一遍?因为中间层不该为了路由去解析请求体。网关、限流器、可观测探针想按方法名分流、想给 tools/call 单独限速,只看头就够了。
关键在下一句:既然中间层按头做决策、服务端按体做执行,两边不一致就是一个漏洞。想象一个网关配了「tools/list 免鉴权,tools/call 要鉴权」,攻击者把头写成 tools/list、体写成 tools/call,就绕过去了。所以规范规定:处理请求体的服务端必须校验头与体的对应值一致,不一致必须回 400 Bad Request 加 JSON-RPC 错误码 -32020(HeaderMismatch)。
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32020,
"message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
}
}另外两条错误路径也要背下来,因为它们的 HTTP 状态码不一样:服务端不支持请求的协议版本,回 400 加 -32022(UnsupportedProtocolVersion)并附上自己支持的版本列表;服务端没实现这个方法,回 404 Not Found 加 -32601。404 这个选择是刻意的——它让客户端能把「这个端点不认识这个方法」和「这个地址根本没有 MCP 端点」区分开。
值的编码有一条坑:HTTP 头值只能是可见 ASCII,工具名叫「查天气」就表示不了。规范给了一个哨兵格式,把 UTF-8 字节 Base64 之后包起来:
Mcp-Name: =?base64?5p+l5aSp5rCU?=服务端必须先解码再比对,否则你自己的校验会把一个完全正常的请求判成头体不一致。这是本课实验里最容易踩到的一处。
顺带提一句 x-mcp-header:服务端在工具的 inputSchema 里给某个参数打这个标记,客户端就会把它的值镜像成 Mcp-Param-{Name} 头,让网关能按参数值(比如地域)路由。它只能用在从 schema 根一路经 properties 可达的基本类型字段上。敏感参数不要这么标——密码、令牌一旦进了头,沿途每一跳都看得见。
会话没了、GET 流没了、断流续传也没了
如果你照着网上的教程写过远程 MCP 服务端,下面三样东西在这一版全部删除,不是弃用,是删除:
协议级会话与 Mcp-Session-Id 头。 服务端不再铸会话 id,也不再用 HTTP DELETE 终止会话。列表类接口的返回不得因连接而异——但可以因请求携带的授权而异,因为凭证是每次请求的输入,不是连接状态。
单独开一条 GET 长连接。 上一版客户端用 GET 开一条流来收服务端主动推的消息,这一版没有了。想收变更通知走 subscriptions/listen(第 3 天讲过),它是一条普通请求,只是响应流一直开着。
Last-Event-ID 断流续传。 流断了,这次请求就丢了,客户端必须用一个新的请求 id 重发。别想着补偿投递,协议这一层不管了。
只支持本版的服务端遇到老客户端的流量,规范给了确定的应对:GET 或 DELETE 打到 MCP 端点回 405 Method Not Allowed;收到 Mcp-Session-Id 头就忽略它,既不铸也不回显;收到 Last-Event-ID 也忽略,流不可续传。
客户端要补的三件事:一是自己管重试(连带着要处理幂等性,这也是工具注解里 idempotentHint 存在的意义);二是订阅改走 subscriptions/listen;三是跨调用状态改用显式句柄,就是第 1 天那个购物车的写法。
服务端要客户端配合怎么办
上一版里,服务端需要用户填个表单、或者需要借客户端的模型算一下时,会主动发一条 JSON-RPC 请求给客户端。这一版把这条路堵死了:服务端绝不能发起 JSON-RPC 请求。
替代方案叫多轮请求(MRTR,Multi Round-Trip Requests):服务端把「我需要什么」塞进结果里返回,客户端拿到后自己去要,要到了再把原请求重发一遍。
Mermaid 源码
sequenceDiagram
participant U as 用户
participant C as 客户端
participant S as 服务端
C->>S: tools/call id=1
Note over S: 缺参会人邮箱
S-->>C: resultType input_required<br/>inputRequests + requestState
C->>U: 弹表单
U-->>C: 填好邮箱
C->>S: tools/call id=2<br/>原参数 + inputResponses + requestState
S-->>C: resultType complete几条规则值得记牢。结果里 resultType 是 "input_required",inputRequests 是一个映射:键由服务端自己取,值是 elicitation/create、sampling/createMessage 或 roots/list 三种请求对象之一。客户端补齐后必须换一个 JSON-RPC id 重发原请求(两次是独立请求),把答案放进 inputResponses,键要对上。只有 tools/call、resources/read、prompts/get 能收到这种结果。服务端不得发客户端没在能力里声明过的类型——对面没说自己能弹表单,你就不能要表单。
requestState 是这套机制的关键:一段只有服务端看得懂的不透明字符串,客户端不得解析、修改或对它做任何假设,重试时必须原样带回。服务端把上下文签进去,就不需要任何服务端存储,也不需要粘性路由——这正是无状态设计能成立的原因。
代价是安全责任全压在服务端身上。规范写得毫不含糊:requestState 必须被当成攻击者可控输入;只要它会影响授权、资源访问或业务逻辑,服务端必须做完整性保护(HMAC 或 AEAD)并拒绝校验失败的值;还应当把认证主体、短过期、原请求标识一起签进去,分别挡跨用户、超时和跨请求三种重放。
import crypto from 'node:crypto'
// 服务端签发:把上下文塞进去,这样重试时不需要任何服务端存储
function sign(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
const mac = crypto.createHmac('sha256', SECRET).update(body).digest('base64url')
return `${body}.${mac}`
}
function verify(state, expect) {
const [body, mac] = state.split('.')
if (!body || !mac) return null
const want = crypto.createHmac('sha256', SECRET).update(body).digest('base64url')
// 定长比较,避免按字节提前返回泄露信息
if (mac.length !== want.length || !crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(want))) return null
const p = JSON.parse(Buffer.from(body, 'base64url').toString())
if (p.principal !== expect.principal) return null // 挡跨用户重放
if (p.origin !== expect.origin) return null // 挡把 A 工具的 state 拿去 B 工具
if (p.exp < Date.now()) return null // 挡超时重放
return p
}import hmac, json, time, base64, hashlib
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
# 服务端签发:把上下文塞进去,这样重试时不需要任何服务端存储
def sign(payload: dict) -> str:
body = _b64(json.dumps(payload).encode())
mac = _b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())
return f"{body}.{mac}"
def verify(state: str, expect: dict) -> dict | None:
body, _, mac = state.partition(".")
if not body or not mac:
return None
want = _b64(hmac.new(SECRET, body.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(mac, want): # 标准库自带定长比较
return None
p = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
if p["principal"] != expect["principal"]: # 挡跨用户重放
return None
if p["origin"] != expect["origin"]: # 挡把 A 工具的 state 拿去 B 工具
return None
if p["exp"] < time.time() * 1000: # 挡超时重放
return None
return p规范还提醒了一句:以上措施缩小重放窗口、挡住跨用户和跨请求复用,但不保证单次使用。要求一个 state 只能兑一次的场景(比如一次性优惠),必须自己在服务端加一层消费记录。
OAuth 2.1 这一层
授权对 MCP 是可选的:HTTP 传输应当遵循这套规范,stdio 传输不应当遵循,改从环境变量取凭证。这条界限画得很清楚,别在本机服务端里折腾 OAuth。
角色映射一句话讲完:MCP 服务端是 OAuth 2.1 的资源服务器,MCP 客户端是 OAuth 2.1 的客户端,授权服务器是第三方(可以和资源服务器同机,也可以独立)。
流程上要记住四个动作。一是发现:客户端无令牌访问,服务端回 401 并在 WWW-Authenticate 里指出受保护资源元数据的位置;服务端必须实现这份元数据(RFC 9728),客户端必须用它来找授权服务器。
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"二是带上资源指示器:客户端必须实现 RFC 8707,在授权请求和令牌请求里都带 resource 参数,值是 MCP 服务端的规范 URI,比如 https://mcp.example.com/mcp(不能缺 scheme、不能带 fragment,且应当尽量具体)。这一步是为了让授权服务器知道「这个令牌发给哪个服务用」,从而把受众写进令牌。
三是受众校验,这是整节最重要的一句:MCP 服务端必须校验令牌是专门发给自己的,校验不过必须回 401;必须只接受对自己资源有效的令牌,不得接受或转接任何其它令牌。
四是权限不足的处理:运行时发现作用域不够,应当回 403 加 error="insufficient_scope" 和所需的 scope,客户端据此做一次提权授权,并且应当把新旧作用域取并集,免得提了这个丢了那个。
不做受众校验的后果有个专门的名字叫令牌转发(token passthrough),规范把它列为明令禁止的反模式。它坏在三处:绕过安全控制——限流、请求校验、流量监控往往挂在「令牌是发给我的」这个前提上,客户端拿着别处的令牌用,这些控制全空转;审计链断裂——服务端分不清是哪个客户端在调,下游日志里的身份又不是真正转发的那个服务端,出事之后没人能还原现场;信任边界被打穿——下游是按「只有上游那个服务能拿到这个令牌」授信的,一旦某个服务被攻破,攻击者就能拿同一个令牌横着走。
容器部署与横向扩容
回到第一节留下的第三个问题:有几个我。
无状态在这里兑现了它的全部价值:协议层面没有任何东西需要在副本之间共享。没有会话表、没有粘性路由、没有连接亲和配置。同一个客户端把三条请求分别打到三个副本,结果完全一样。这是这一版最实在的收益,也是它值得为「每条请求都重复带一遍版本和能力」买单的原因。
services:
mcp-a:
build: .
ports: ['3034:3034']
environment:
# 唯一需要共享的是签名密钥:A 签发的 requestState 要能在 B 验过
REQUEST_STATE_SECRET: shared-dev-secret
stop_grace_period: 10s
mcp-b:
build: .
ports: ['4034:3034']
environment:
REQUEST_STATE_SECRET: shared-dev-secret
stop_grace_period: 10s三件运维上的事顺手做掉。健康检查要另开一个路径,不能打 MCP 端点——那个端点只收 POST,用 GET 会拿到 405。优雅停机要真的等:收到 SIGTERM 后先停止接受新连接,再等手头的请求跑完;MCP 的工具调用动辄几十秒,stop_grace_period 要设得比最长工具耗时长。容器里绑 0.0.0.0、裸机上绑 127.0.0.1,这两件事不要混。
最后留一个坑:普通请求不需要连接亲和,但 subscriptions/listen 那条长活流仍然是有状态的连接——它一挂,客户端得重新订阅。所以负载均衡的空闲超时要设得比保活间隔长,滚动发布时也要接受订阅会断一次。无状态说的是协议,不是 TCP 连接。
源码导读
动手实验
今天的实验刻意不用官方 SDK:JavaScript SDK 到 1.30.0 为止实现的还是上一版传输,照着它写你会把会话和 GET 流又学一遍。手写的端点连注释也就两百来行,规范的每条 MUST 都能一眼对上。跑起来 starter 是 3 项绿 5 项红,你的任务是把那 5 个红的变绿。
- 先读 solution 的 server.ts,找到来源校验、请求头校验、以及 JSON 与事件流两个响应分支各在哪一行。
- 在 starter 的 checkHeaders 里补全头与体的一致性校验,跑自测看第 3、4 项从红变绿。
- 补全事件流分支,用 curl 加 -N 参数手动发一次慢工具,肉眼看到进度一条条冒出来而不是最后一起到。
- 补全预约会议工具的多轮请求返回,再补上 requestState 的验签,看第 7、8 项变绿。
- 用实验根目录的 compose 起两个副本,把同一条 curl 分别打到 3034 和 4034,确认返回一致。
面试题
今天 3 道题在下方题库区,侧重无状态传输的取舍、请求头与请求体一致性、以及令牌受众校验。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能实现一个只有单个 POST 端点的 Streamable HTTP 服务端,并按需返回 JSON 或事件流
- 能说出这一版删掉了会话、GET 流与断流续传之后,客户端要怎么改
- 能解释 MCP 服务端作为受保护资源必须做的令牌受众校验,以及不做会怎样
- 能默写出三个必填请求头,以及头体不一致时该回哪个错误码
- 实验的 5 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D5)我们换到桌子的另一边,手写一个 MCP 客户端。顺序仍然是有意的:只有先把服务端的两种响应形态、必填请求头、多轮请求都实现过一遍,才知道客户端那边要处理多少种情况。 今天你写的是「服务端可以这么回」,明天写的是「客户端必须全都接得住」——包括把多个服务端的工具合并给模型时那个必然出现的重名问题。
面试题库
2026-07-28 去掉了协议级会话。那一个需要跨调用保存状态的远程服务端——比如购物车、数据库事务——应该怎么设计?The 2026-07-28 revision removed protocol-level sessions. How should a remote server that needs cross-call state — a shopping cart, a database transaction — be designed?
国内高频海外高频进阶#statelessness#api-design分析过程 · 先想清楚再作答
- 这题在筛「有没有把无状态当成设计约束」。答「用 Mcp-Session-Id 头」的当场出局,那个头这一版已经删了;答「存在服务端内存里按连接查」的同样出局,因为客户端根本不保证复用连接。
- 先给结构:状态必须由客户端携带,服务端只认请求里带来的东西。落地成两种形态——一是服务端铸造的显式句柄,创建工具返回一个 id,后续调用把它当普通工具参数传回来;二是签过名的不透明状态串,比如多轮请求里的 requestState,服务端把上下文签进去,重试时原样收回。
- 两者的差别在于「谁存数据」:句柄背后的购物车内容还是存在服务端的库里,句柄只是主键;requestState 是把上下文本身编码进字符串,服务端零存储。前者适合长期存在的业务对象,后者适合一次交互内的续接。
- 结论:不管哪种,服务端内存里都不为某个客户端留东西,所以任何副本都能处理任何请求,扩容不需要粘性路由——这正是这次改动想换来的东西。
- 安全是必须主动补的一句:句柄是名字不是凭证。要用安全随机数生成、绑定到已认证的主体(按 user_id 加 handle 做键)、设过期时间,并且每次调用重新校验调用者身份。规范明确写了服务端不得把持有句柄当成身份认证。requestState 同理,它经客户端转手,是攻击者可控输入,必须 HMAC 或 AEAD 验签,并把主体、原请求标识、短过期签进去。
- 可预期的追问:多副本时 requestState 怎么办?答案是所有副本共享签名密钥即可,这仍然是无状态的——状态在客户端手里,副本只负责验签。追问二可能是「怎么保证一次性」,答案是签名只能缩小重放窗口,真要单次消费得自己在服务端加一层消费记录。
How to reason about it · think before answering
- The screen is whether you treat statelessness as a design constraint. Answering 'use Mcp-Session-Id' fails immediately — that header was removed. So does 'keep it in server memory keyed by connection', since clients are not required to reuse connections.
- Give the structure first: state must travel with the client, and the server trusts only what arrives in the request. Two concrete shapes — a server-minted explicit handle returned by a creation tool and passed back as an ordinary tool argument, or a signed opaque blob like the requestState used by multi round-trip requests.
- The difference is who stores the data. A handle is just a primary key into server-side storage; a requestState encodes the context itself, so the server stores nothing. Handles suit long-lived business objects, requestState suits continuing a single interaction.
- Conclusion: either way the server keeps nothing per client in memory, so any replica can serve any request and scaling needs no sticky routing — which is exactly what the change was buying.
- Volunteer the security half: a handle is a name, not a credential. Generate it from a secure random source, bind it server-side to the authenticated principal (key storage as user id plus handle), expire it, and re-authorize on every call — the spec says possession of a handle must not be treated as authentication. requestState passes through the client, so it is attacker-controlled input and must be integrity-protected with HMAC or AEAD, carrying the principal, an originating-request identifier, and a short expiry.
- Likely follow-up: what about requestState across replicas? Share the signing key; it is still stateless because the state lives with the client and replicas only verify. A second follow-up is single use — signing bounds the replay window but does not guarantee one-time consumption, which needs a server-side redemption record.
答题要点
- 状态必须由客户端携带:服务端铸造显式句柄,作为普通工具参数在后续调用里传回
- 一次交互内的续接可以用签名的不透明状态串,服务端零存储,多副本共享签名密钥即可
- 句柄不是凭证:安全随机生成、绑定已认证主体、设过期,每次调用重新鉴权
- 收益是任何副本能处理任何请求,扩容不需要粘性路由,重启后重发即可
Key points
- State travels with the client: the server mints an explicit handle that later calls pass back as an ordinary tool argument
- Within one interaction, a signed opaque blob works with zero server storage; replicas just share the signing key
- A handle is not a credential: securely random, bound to the authenticated principal, expiring, re-authorized on every call
- The payoff is that any replica serves any request, so scaling needs no sticky routing and retries are cheap
Streamable HTTP 要求 Mcp-Method 头必须和请求体里的 method 一致。为什么要抄一遍?不校验会有什么风险?Streamable HTTP requires the Mcp-Method header to match the method in the request body. Why mirror it at all, and what breaks if the server does not validate the match?
国内高频海外高频进阶#transport#security分析过程 · 先想清楚再作答
- 这题的题眼在后半句。只答「方便网关路由」是答了一半,面试官等的是「不一致会怎样」——能不能自己举出攻击场景,是区分「读过规范」和「理解规范」的地方。
- 先说为什么镜像:中间层不该为了做决策去解析请求体。负载均衡想按方法分流、限流器想给 tools/call 单独设阈值、可观测探针想打标签,只看头就够了,不用把几十 KB 的 body 反序列化一遍。同理还有 Mcp-Name(取自 params.name 或 params.uri)和 MCP-Protocol-Version。
- 再推风险:既然中间层按头决策、服务端按体执行,两个事实来源就分叉了。举个具体的:网关配了「tools/list 免鉴权、tools/call 要鉴权」,攻击者把头写成 tools/list、体写成 tools/call,鉴权就被绕过去了。同样的套路可以绕限流、绕审计、绕按参数值做的地域隔离。
- 结论:所以规范规定处理请求体的服务端必须校验头体一致,不一致必须回 400 加 -32020(HeaderMismatch)。这不是格式洁癖,是把「两个事实来源」重新合并成一个。
- 实现上有个坑值得主动说:头值只能是可见 ASCII,非 ASCII 的工具名或资源 URI 要用 =?base64?...?= 哨兵格式编码,服务端必须先解码再比对,否则自己的校验会把正常请求判成不一致。整数值应当按数值比较而不是按字符串比较。
- 可预期的追问:中间层自己要不要校验?规范建议按头做策略的中间层先确认 MCP-Protocol-Version 指向的是一个要求头体校验的版本,版本更老或头缺失时应当直接拒绝,而不是信任未经校验的头值。
How to reason about it · think before answering
- The real question is the second half. 'It helps gateways route' is half an answer; the interviewer is waiting for a concrete attack, which separates having read the spec from having understood it.
- Why mirror: intermediaries should not parse the body to make decisions. A load balancer routing by method, a rate limiter capping tools/call, an observability probe tagging spans — all can read a header instead of deserializing tens of kilobytes. The same applies to Mcp-Name (from params.name or params.uri) and MCP-Protocol-Version.
- Then derive the risk: if intermediaries decide on the header and the server executes on the body, there are two sources of truth. Concretely, a gateway configured as 'tools/list is unauthenticated, tools/call is authenticated' is bypassed by sending the header as tools/list and the body as tools/call. The same trick evades rate limits, audit tagging, and per-parameter regional isolation.
- Conclusion: the spec therefore requires any server that processes the body to validate the match and reject with 400 plus -32020 (HeaderMismatch). It is not pedantry — it collapses two sources of truth back into one.
- Volunteer the implementation trap: header values are visible ASCII only, so non-ASCII tool names or resource URIs use the =?base64?...?= sentinel, and the server must decode before comparing or its own check will reject valid requests. Integer values should be compared numerically, not as strings.
- Likely follow-up: should intermediaries validate too? The spec advises that any intermediary enforcing policy from mirrored headers first confirm MCP-Protocol-Version names a revision that mandates header-body validation, and otherwise reject rather than trust unvalidated headers.
答题要点
- 镜像是为了让网关、限流器、探针不用解析请求体就能路由和打标签
- 不校验就有两个事实来源:头写 tools/list、体写 tools/call 可以绕过按方法配置的鉴权与限流
- 规范要求处理请求体的服务端必须校验一致性,不一致回 400 与 -32020
- 非 ASCII 值用 base64 哨兵格式,服务端必须先解码再比对;整数按数值比较
Key points
- Mirroring lets gateways, rate limiters, and probes route and tag without parsing the body
- Skipping validation creates two sources of truth: header tools/list with body tools/call bypasses per-method auth and limits
- The spec requires any body-processing server to validate the match and return 400 with -32020 on mismatch
- Non-ASCII values use the base64 sentinel, so decode before comparing; compare integers numerically
为什么 MCP 服务端绝对不能把客户端给的访问令牌直接转发给下游 API?Why must an MCP server never forward the client's access token straight to a downstream API?
国内高频海外高频深入#oauth#security分析过程 · 先想清楚再作答
- 这题在考安全边界的直觉。答「不安全」「会泄露」是空话;规范给这个反模式起了名字叫令牌转发(token passthrough),并明令禁止,能说出它坏在哪三处才算过关。
- 先把前提说清:MCP 服务端在授权体系里是 OAuth 2.1 的资源服务器,它必须校验收到的令牌受众就是自己(客户端靠 RFC 8707 的 resource 参数让授权服务器把受众写进令牌),并且必须只接受对自己资源有效的令牌,不得接受或转接其它令牌。
- 拆危害的角度是「谁的假设被打破了」。第一,绕过安全控制:限流、请求校验、流量监控往往挂在「这个令牌是发给我的」这个前提上,客户端拿着别处的令牌直连或经服务端转发,这些控制全空转。第二,审计链断裂:服务端分不清是哪个客户端在调(上游令牌对它可能是不透明的),下游日志里的身份又不是真正在转发的那个服务端,出事之后没人能还原现场;持有失窃令牌的人还能把服务端当成数据外泄的代理。第三,信任边界被打穿:下游是按「只有上游那个服务能拿到这个令牌」授信的,一旦某个服务被攻破,同一个令牌就能横着走。
- 结论:服务端要访问下游,就得自己作为 OAuth 客户端去拿一份属于自己的凭证,和客户端给自己的令牌完全隔离。
- 正确做法要一起说:需要代表用户访问第三方时走 URL 模式的补充输入,让用户在浏览器里直接和第三方完成授权,服务端把第三方令牌存在自己这边并绑定到已认证的用户身份。规范要求第三方凭证不得经由 MCP 客户端传输。
- 可预期的追问:那和混淆代理是什么关系?令牌转发是受众校验失败的下游后果,混淆代理是代理型服务端用静态 client id 加上跳过按客户端的同意确认造成的授权码劫持——两者都源于「服务端替别人做决定却没确认这个别人是谁」。这一条第 6 天会展开。
How to reason about it · think before answering
- This probes your instinct for trust boundaries. 'It is insecure' is empty; the spec names this anti-pattern token passthrough and forbids it, so you need the three concrete failure modes.
- Set up the premise: in the authorization model an MCP server is an OAuth 2.1 resource server. It must validate that tokens were issued with itself as the audience — clients make that possible via the RFC 8707 resource parameter — and must accept only tokens valid for its own resources, accepting or transiting nothing else.
- Derive the harm by asking whose assumption breaks. First, security controls are circumvented: rate limiting, request validation, and traffic monitoring hang off 'this token was issued to me', and a token minted elsewhere makes them no-ops. Second, the audit trail breaks: the server cannot distinguish clients when the upstream token is opaque to it, downstream logs show an identity that is not the forwarding server, and a thief of a stolen token can use the server as an exfiltration proxy. Third, the trust boundary is punctured: downstream grants trust on the assumption that only the upstream service holds the token, so one compromise travels sideways.
- Conclusion: to call downstream, the server must obtain its own credential as an OAuth client, fully isolated from the token the client presented to it.
- Give the correct pattern too: for third-party access on the user's behalf, use URL-mode elicitation so the user authorizes the third party directly in a browser, and the server stores those tokens bound to the authenticated user identity. The spec requires third-party credentials never to transit the MCP client.
- Likely follow-up: how does this relate to the confused deputy? Token passthrough is the downstream consequence of failed audience validation, while the confused deputy is authorization-code hijacking caused by a proxy server combining a static client id with skipped per-client consent. Both come from a server acting for someone without confirming who that someone is.
答题要点
- MCP 服务端是 OAuth 2.1 资源服务器,必须校验令牌受众是自己,不得接受或转接其它令牌
- 转发会绕过挂在受众上的限流、请求校验与流量监控
- 审计链断裂:服务端分不清调用方,下游看到的身份也不是真正的转发者,还可能被当成外泄代理
- 正确做法是服务端自己作为 OAuth 客户端取下游凭证,第三方凭证绝不经由 MCP 客户端
Key points
- An MCP server is an OAuth 2.1 resource server: it must validate that it is the token audience and must not accept or transit other tokens
- Forwarding bypasses rate limiting, request validation, and monitoring that assume audience-bound tokens
- The audit trail breaks: the server cannot identify callers, downstream sees the wrong identity, and the server can become an exfiltration proxy
- The correct pattern is for the server to obtain its own downstream credential as an OAuth client, with third-party credentials never transiting the MCP client
评论
登录后即可参与讨论
还没有评论,来说第一句。