Dayward AI
Week 1 · D4About 6 hours

Remote MCP: the Streamable HTTP Binding, the Stateless Model and Request Metadata, OAuth 2.1 Authorization, Container Deployment

Move a server from a local subprocess onto the public internet: one POST endpoint, two response shapes, one set of required headers, plus OAuth 2.1 audience validation and a container deployment that can scale out.

Today's goals 0/3

Sign in to tick these off and save your progress.

今日目标

  1. 能实现一个只有单个 POST 端点的 Streamable HTTP 服务端,并按需返回 JSON 或事件流
  2. 能说出这一版删掉了会话、GET 流与断流续传之后,客户端要怎么改
  3. 能解释 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/jsontext/event-stream;请求体必须是单条 JSON-RPC 请求或通知,不能是 JSON-RPC 响应。

服务端收到后分两种情况。请求体是通知(没有 id)时,接受就回 202 Accepted 且不带 body。是请求时,服务端自己决定回哪种:

  • Content-Type: application/json——一个 JSON 对象,完事。
  • Content-Type: text/event-stream——一条只属于这次请求的事件流,先流出若干条与本请求相关的通知,最后流出正式响应。

客户端两种都必须支持,因为选择权在服务端。这是最容易漏的一条:很多自己写的客户端只处理了 application/json,接上一个会流式回进度的服务端就直接崩了。

事件流上有三条硬规矩。流上可以发 notifications/progressnotifications/message,但它们必须与发起这条流的请求相关;服务端绝不能在这条流上发独立的 JSON-RPC 请求(上一版这么干,本版明令禁止,理由下一节说);最终响应应当终止这条流。两条工程建议也别省:开流时带上 X-Accel-Buffering: no,否则 nginx 之类的反向代理会把事件攒着一起发,本机测一切正常、上了线进度全糊在最后一秒;长活的流要周期性发一行以冒号开头的 SSE 注释做保活,免得被中间层按空闲超时掐断。

还有一条容易忽略的语义:客户端关闭事件流,就是这次请求的取消信号。因为每条请求有自己独立的响应流,断开是没有歧义的,所以 HTTP 上根本不需要 notifications/cancelled 这条消息(它只在 stdio 上用)。

endpoint.js
// 一个端点,两种回法。真正的分支只有这一处
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() // 最终响应终止这条流
}

必填请求头:协议版本、方法名、目标名

这一版给 Streamable HTTP 加了一套必填请求头,它们把请求体里的几个关键字段镜像到 HTTP 头上。

取自请求体的什么时候必填
MCP-Protocol-Version_meta 里的协议版本每个 POST
Mcp-Methodmethod每个 POST
Mcp-Nameparams.nameparams.uritools/callresources/readprompts/get
httphttp
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 错误码 -32020HeaderMismatch)。

JSONJSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32020,
    "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
  }
}

另外两条错误路径也要背下来,因为它们的 HTTP 状态码不一样:服务端不支持请求的协议版本,回 400-32022UnsupportedProtocolVersion)并附上自己支持的版本列表;服务端没实现这个方法,回 404 Not Found-32601404 这个选择是刻意的——它让客户端能把「这个端点不认识这个方法」和「这个地址根本没有 MCP 端点」区分开。

值的编码有一条坑:HTTP 头值只能是可见 ASCII,工具名叫「查天气」就表示不了。规范给了一个哨兵格式,把 UTF-8 字节 Base64 之后包起来:

TextText
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):服务端把「我需要什么」塞进结果里返回,客户端拿到后自己去要,要到了再把原请求重发一遍

tools/call id=1 resultType input_requiredinputRequests + requestState 弹表单 填好邮箱 tools/call id=2原参数 + inputResponses + requestState resultType complete 缺参会人邮箱 用户 客户端 服务端
Mermaid source
mermaidmermaid
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/createsampling/createMessageroots/list 三种请求对象之一。客户端补齐后必须换一个 JSON-RPC id 重发原请求(两次是独立请求),把答案放进 inputResponses,键要对上。只有 tools/callresources/readprompts/get 能收到这种结果。服务端不得发客户端没在能力里声明过的类型——对面没说自己能弹表单,你就不能要表单。

requestState 是这套机制的关键:一段只有服务端看得懂的不透明字符串,客户端不得解析、修改或对它做任何假设,重试时必须原样带回。服务端把上下文签进去,就不需要任何服务端存储,也不需要粘性路由——这正是无状态设计能成立的原因。

代价是安全责任全压在服务端身上。规范写得毫不含糊:requestState 必须被当成攻击者可控输入;只要它会影响授权、资源访问或业务逻辑,服务端必须做完整性保护(HMAC 或 AEAD)并拒绝校验失败的值;还应当把认证主体、短过期、原请求标识一起签进去,分别挡跨用户、超时和跨请求三种重放。

request-state.js
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
}

规范还提醒了一句:以上措施缩小重放窗口、挡住跨用户和跨请求复用,但不保证单次使用。要求一个 state 只能兑一次的场景(比如一次性优惠),必须自己在服务端加一层消费记录。

OAuth 2.1 这一层

授权对 MCP 是可选的:HTTP 传输应当遵循这套规范,stdio 传输不应当遵循,改从环境变量取凭证。这条界限画得很清楚,别在本机服务端里折腾 OAuth。

角色映射一句话讲完:MCP 服务端是 OAuth 2.1 的资源服务器,MCP 客户端是 OAuth 2.1 的客户端,授权服务器是第三方(可以和资源服务器同机,也可以独立)。

流程上要记住四个动作。一是发现:客户端无令牌访问,服务端回 401 并在 WWW-Authenticate 里指出受保护资源元数据的位置;服务端必须实现这份元数据(RFC 9728),客户端必须用它来找授权服务器。

httphttp
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必须只接受对自己资源有效的令牌,不得接受或转接任何其它令牌

四是权限不足的处理:运行时发现作用域不够,应当回 403error="insufficient_scope" 和所需的 scope,客户端据此做一次提权授权,并且应当把新旧作用域取并集,免得提了这个丢了那个。

不做受众校验的后果有个专门的名字叫令牌转发(token passthrough),规范把它列为明令禁止的反模式。它坏在三处:绕过安全控制——限流、请求校验、流量监控往往挂在「令牌是发给我的」这个前提上,客户端拿着别处的令牌用,这些控制全空转;审计链断裂——服务端分不清是哪个客户端在调,下游日志里的身份又不是真正转发的那个服务端,出事之后没人能还原现场;信任边界被打穿——下游是按「只有上游那个服务能拿到这个令牌」授信的,一旦某个服务被攻破,攻击者就能拿同一个令牌横着走。

容器部署与横向扩容

回到第一节留下的第三个问题:有几个我。

无状态在这里兑现了它的全部价值:协议层面没有任何东西需要在副本之间共享。没有会话表、没有粘性路由、没有连接亲和配置。同一个客户端把三条请求分别打到三个副本,结果完全一样。这是这一版最实在的收益,也是它值得为「每条请求都重复带一遍版本和能力」买单的原因。

YAMLYAML
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 连接。

源码导读

动手实验

🧪 D4 实验:一个可容器化部署的 Streamable HTTP MCP 服务端

Code location: labs/mcp-7days/day-04-streamable-http-deploy

验收标准:

  1. starter 里的 4 个练习点全部补完,MOCK=1 SELFTEST=1 pnpm start 的 8 项自测全绿且退出码为 0
  2. 慢工具那一项能看到 content-typetext/event-stream,且最终响应之前至少收到 2 条 notifications/progress
  3. MCP-Protocol-Version 头、以及 Mcp-Method 头与请求体不一致时,都回 400-32020
  4. 预约会议的工具第一轮返回 resultTypeinput_required,客户端带上 inputResponses 和原样的 requestState、换一个 JSON-RPC id 重试后拿到最终结果
  5. 用 compose 起两个副本后,同一条 tools/call 打到 3034 和 4034 都能拿到相同结果

今天的实验刻意不用官方 SDK:JavaScript SDK 到 1.30.0 为止实现的还是上一版传输,照着它写你会把会话和 GET 流又学一遍。手写的端点连注释也就两百来行,规范的每条 MUST 都能一眼对上。跑起来 starter 是 3 项绿 5 项红,你的任务是把那 5 个红的变绿。

  1. 先读 solution 的 server.ts,找到来源校验、请求头校验、以及 JSON 与事件流两个响应分支各在哪一行。
  2. 在 starter 的 checkHeaders 里补全头与体的一致性校验,跑自测看第 3、4 项从红变绿。
  3. 补全事件流分支,用 curl 加 -N 参数手动发一次慢工具,肉眼看到进度一条条冒出来而不是最后一起到。
  4. 补全预约会议工具的多轮请求返回,再补上 requestState 的验签,看第 7、8 项变绿。
  5. 用实验根目录的 compose 起两个副本,把同一条 curl 分别打到 3034 和 4034,确认返回一致。

面试题

今天 3 道题在下方题库区,侧重无状态传输的取舍、请求头与请求体一致性、以及令牌受众校验。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。

检查清单与明日预告

  • 能实现一个只有单个 POST 端点的 Streamable HTTP 服务端,并按需返回 JSON 或事件流
  • 能说出这一版删掉了会话、GET 流与断流续传之后,客户端要怎么改
  • 能解释 MCP 服务端作为受保护资源必须做的令牌受众校验,以及不做会怎样
  • 能默写出三个必填请求头,以及头体不一致时该回哪个错误码
  • 实验的 5 条验收标准全部通过
  • 3 道面试题不看要点也能答出至少 2 道

明天(D5)我们换到桌子的另一边,手写一个 MCP 客户端。顺序仍然是有意的:只有先把服务端的两种响应形态、必填请求头、多轮请求都实现过一遍,才知道客户端那边要处理多少种情况。 今天你写的是「服务端可以这么回」,明天写的是「客户端必须全都接得住」——包括把多个服务端的工具合并给模型时那个必然出现的重名问题。

Interview questions

  • 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?2026-07-28 去掉了协议级会话。那一个需要跨调用保存状态的远程服务端——比如购物车、数据库事务——应该怎么设计?
    Common in ChinaCommon overseasIntermediate#statelessness#api-design

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.

    分析过程 · 先想清楚再作答

    1. 这题在筛「有没有把无状态当成设计约束」。答「用 Mcp-Session-Id 头」的当场出局,那个头这一版已经删了;答「存在服务端内存里按连接查」的同样出局,因为客户端根本不保证复用连接。
    2. 先给结构:状态必须由客户端携带,服务端只认请求里带来的东西。落地成两种形态——一是服务端铸造的显式句柄,创建工具返回一个 id,后续调用把它当普通工具参数传回来;二是签过名的不透明状态串,比如多轮请求里的 requestState,服务端把上下文签进去,重试时原样收回。
    3. 两者的差别在于「谁存数据」:句柄背后的购物车内容还是存在服务端的库里,句柄只是主键;requestState 是把上下文本身编码进字符串,服务端零存储。前者适合长期存在的业务对象,后者适合一次交互内的续接。
    4. 结论:不管哪种,服务端内存里都不为某个客户端留东西,所以任何副本都能处理任何请求,扩容不需要粘性路由——这正是这次改动想换来的东西。
    5. 安全是必须主动补的一句:句柄是名字不是凭证。要用安全随机数生成、绑定到已认证的主体(按 user_id 加 handle 做键)、设过期时间,并且每次调用重新校验调用者身份。规范明确写了服务端不得把持有句柄当成身份认证。requestState 同理,它经客户端转手,是攻击者可控输入,必须 HMAC 或 AEAD 验签,并把主体、原请求标识、短过期签进去。
    6. 可预期的追问:多副本时 requestState 怎么办?答案是所有副本共享签名密钥即可,这仍然是无状态的——状态在客户端手里,副本只负责验签。追问二可能是「怎么保证一次性」,答案是签名只能缩小重放窗口,真要单次消费得自己在服务端加一层消费记录。

    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 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?Streamable HTTP 要求 Mcp-Method 头必须和请求体里的 method 一致。为什么要抄一遍?不校验会有什么风险?
    Common in ChinaCommon overseasIntermediate#transport#security

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.

    分析过程 · 先想清楚再作答

    1. 这题的题眼在后半句。只答「方便网关路由」是答了一半,面试官等的是「不一致会怎样」——能不能自己举出攻击场景,是区分「读过规范」和「理解规范」的地方。
    2. 先说为什么镜像:中间层不该为了做决策去解析请求体。负载均衡想按方法分流、限流器想给 tools/call 单独设阈值、可观测探针想打标签,只看头就够了,不用把几十 KB 的 body 反序列化一遍。同理还有 Mcp-Name(取自 params.name 或 params.uri)和 MCP-Protocol-Version。
    3. 再推风险:既然中间层按头决策、服务端按体执行,两个事实来源就分叉了。举个具体的:网关配了「tools/list 免鉴权、tools/call 要鉴权」,攻击者把头写成 tools/list、体写成 tools/call,鉴权就被绕过去了。同样的套路可以绕限流、绕审计、绕按参数值做的地域隔离。
    4. 结论:所以规范规定处理请求体的服务端必须校验头体一致,不一致必须回 400 加 -32020(HeaderMismatch)。这不是格式洁癖,是把「两个事实来源」重新合并成一个。
    5. 实现上有个坑值得主动说:头值只能是可见 ASCII,非 ASCII 的工具名或资源 URI 要用 =?base64?...?= 哨兵格式编码,服务端必须先解码再比对,否则自己的校验会把正常请求判成不一致。整数值应当按数值比较而不是按字符串比较。
    6. 可预期的追问:中间层自己要不要校验?规范建议按头做策略的中间层先确认 MCP-Protocol-Version 指向的是一个要求头体校验的版本,版本更老或头缺失时应当直接拒绝,而不是信任未经校验的头值。

    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

    答题要点

    • 镜像是为了让网关、限流器、探针不用解析请求体就能路由和打标签
    • 不校验就有两个事实来源:头写 tools/list、体写 tools/call 可以绕过按方法配置的鉴权与限流
    • 规范要求处理请求体的服务端必须校验一致性,不一致回 400 与 -32020
    • 非 ASCII 值用 base64 哨兵格式,服务端必须先解码再比对;整数按数值比较
  • Why must an MCP server never forward the client's access token straight to a downstream API?为什么 MCP 服务端绝对不能把客户端给的访问令牌直接转发给下游 API?
    Common in ChinaCommon overseasDeep dive#oauth#security

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.

    分析过程 · 先想清楚再作答

    1. 这题在考安全边界的直觉。答「不安全」「会泄露」是空话;规范给这个反模式起了名字叫令牌转发(token passthrough),并明令禁止,能说出它坏在哪三处才算过关。
    2. 先把前提说清:MCP 服务端在授权体系里是 OAuth 2.1 的资源服务器,它必须校验收到的令牌受众就是自己(客户端靠 RFC 8707 的 resource 参数让授权服务器把受众写进令牌),并且必须只接受对自己资源有效的令牌,不得接受或转接其它令牌。
    3. 拆危害的角度是「谁的假设被打破了」。第一,绕过安全控制:限流、请求校验、流量监控往往挂在「这个令牌是发给我的」这个前提上,客户端拿着别处的令牌直连或经服务端转发,这些控制全空转。第二,审计链断裂:服务端分不清是哪个客户端在调(上游令牌对它可能是不透明的),下游日志里的身份又不是真正在转发的那个服务端,出事之后没人能还原现场;持有失窃令牌的人还能把服务端当成数据外泄的代理。第三,信任边界被打穿:下游是按「只有上游那个服务能拿到这个令牌」授信的,一旦某个服务被攻破,同一个令牌就能横着走。
    4. 结论:服务端要访问下游,就得自己作为 OAuth 客户端去拿一份属于自己的凭证,和客户端给自己的令牌完全隔离。
    5. 正确做法要一起说:需要代表用户访问第三方时走 URL 模式的补充输入,让用户在浏览器里直接和第三方完成授权,服务端把第三方令牌存在自己这边并绑定到已认证的用户身份。规范要求第三方凭证不得经由 MCP 客户端传输。
    6. 可预期的追问:那和混淆代理是什么关系?令牌转发是受众校验失败的下游后果,混淆代理是代理型服务端用静态 client id 加上跳过按客户端的同意确认造成的授权码劫持——两者都源于「服务端替别人做决定却没确认这个别人是谁」。这一条第 6 天会展开。

    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

    答题要点

    • MCP 服务端是 OAuth 2.1 资源服务器,必须校验令牌受众是自己,不得接受或转接其它令牌
    • 转发会绕过挂在受众上的限流、请求校验与流量监控
    • 审计链断裂:服务端分不清调用方,下游看到的身份也不是真正的转发者,还可能被当成外泄代理
    • 正确做法是服务端自己作为 OAuth 客户端取下游凭证,第三方凭证绝不经由 MCP 客户端

Comments

Sign in to join the discussion

No comments yet — be the first.