一句话判断
MCP 的 OAuth 不是一个可以从传输层里“抽出来单独做登录”的外围功能,它本身就是传输协议状态机的一部分。Cloudflare Agents 在 agents@0.16.2 里接入官方 @modelcontextprotocol/conformance 套件后,立刻抓到了两个 OAuth bug:一个是在 401 挑战后换了新 transport,导致丢失 WWW-Authenticate 里带回来的 resource_metadata,最终把 token 交换错打到默认 /token;另一个是在 mid-session 401 之后重授权成功,却因为旧 transport 没 detach 而永久卡死在 Already connected to a transport。我觉得这条信号非常重要:MCP 的授权流程不能被实现成“外部登录插件”,而必须被当成 runtime transport contract 来设计、测试和恢复。
背景:为什么这件事值得单独拿出来讲
过去很多团队做 Agent 平台时,会把 MCP 的授权想得过于“普通 Web”:
- 先连上 server;
- 如果没权限,就跳一个 OAuth 页面;
- 拿到 token 后,再重新发起连接;
- 只要最终有 token,就算授权链路完成。
这个心智模型的问题在于,它把 OAuth 当成了一个与协议执行解耦的前置步骤。但 MCP 不是这样。
MCP draft authorization 明确把授权失败建模为协议路径的一部分:
- 初次无权限时,server 应返回
401;
- scope 不足时,server 应返回
403 insufficient_scope;
- 关键元数据通过
WWW-Authenticate 头和 resource_metadata 暴露;
- client 需要基于挑战信息做 step-up,并重试原始请求。
也就是说,授权挑战不是“跳出协议去登录一下”,而是协议本身在运行中发出的控制信号。
这次 Cloudflare 暴露出的两个真正工程问题
1. 丢的不是 URL,丢的是 challenge 上下文
第一个 bug 看起来像是“token endpoint 地址错了”,但本质并不是配置错误,而是 challenge context 丢失。
Cloudflare 在 PR #1722 里写得很清楚:finishAuth 如果跑在一个 freshly created transport 上,就会丢掉最初 401 响应里的 resource metadata URL。这样客户端虽然知道“要去换 token”,却不知道应该按哪个受保护资源刚刚给出的元数据去完成交换,于是就退回默认 /token 路径。只要授权服务器不是默认布局,流程就会失败。
这里最关键的工程启发是:
WWW-Authenticate 不是一个可有可无的提示;
resource_metadata 不是可重新猜测的衍生信息;
- 401 challenge 携带的是当前 transport 上下文的一部分。
换句话说,授权恢复必须延续 challenge 所在的协议语境,而不是只保留“去登录”这个抽象动作。
对 Dewey/OPC 这类要接第三方 MCP server 的系统来说,这意味着:如果你把认证包装在一个 gateway、浏览器弹窗或 host-side helper 里,却没有把 challenge 头、resource metadata、scope 提示、原始请求标识一起保留下来,后面即使“登录成功”,也可能已经脱离了原来的协议现场。
2. step-up authorization 本质上是一次运行中恢复,不是重新开局
第二个 bug更有代表性:mid-session 401 之后,用户完成了重新授权,但 init() 永远失败,因为之前的 transport 还挂着,于是报 Already connected to a transport。
这说明很多实现虽然“支持 OAuth”,却并没有真正支持运行中的重授权。
MCP authorization draft 对 step-up 的描述其实很明确:
- 解析当前错误里的 scope / metadata;
- 将新 scope 与已有 scope 做 union;
- 发起 re-authorization;
- retry 原始请求;
- 并且要有 retry limit,避免无限失败循环。
这整个过程更像是一次 in-flight recovery path,而不是重新 new 一个 client、重新开始一个独立会话。
所以真正该被建模的不是“OAuth 成不成功”,而是:
- 原 transport 是否被正确关闭或脱附;
- 新 transport 是否带着上一轮的授权语义回来;
- 原请求是否还能被幂等地重试;
- scope 升级是不是累积的,而不是覆盖式的。
如果这些都没做,所谓“支持 step-up”通常只是 demo 级支持。
这次最值得注意的,不只是修了两个 bug,而是 bug 是被官方 conformance suite 第一时间打出来的。
Cloudflare 在 PR 里把官方 @modelcontextprotocol/conformance 套件直接跑在 workerd 里的真实实现上,而不是在一个 Node mock 上凑合。它覆盖了:
- client 侧 26 个 scenario、334 个 checks;
- 包括 OAuth metadata variants、scope step-up、token endpoint auth methods、pre-registration、backcompat 和 offline-access drafts;
- server 侧两个实现各 40 个 checks。
这给我的一个强判断是:MCP 的很多 bug 根本不是“单元测试写少了”,而是只有在真实 transport、真实 callback、真实 runtime 里才会暴露。
尤其是授权链路,最容易在这些地方出问题:
- 401/403 challenge header 是否被正确保留;
- callback 回来后是否还认得原来的 resource;
- re-auth 之后旧连接有没有彻底清理;
- 多种 metadata 变体和历史兼容路径是否都过关。
所以如果一个平台说自己“兼容 MCP”,但没有跑官方 conformance、没有覆盖 OAuth 场景、没有在真实宿主环境里验证,那这个兼容性声明通常还不够硬。
对 Agent Runtime 设计的几个直接启发
1. transport 应该拥有 auth challenge state
不要把 OAuth 仅仅挂在全局 auth manager 上。至少以下信息应被视为 transport-bound state:
- 最近一次 challenge 的
WWW-Authenticate 内容;
resource_metadata URI;
- 原始请求与目标 resource 的绑定;
- 已申请 scope 集合及其升级历史。
否则实现很容易退化成“我知道用户登录过,但我不知道这次协议交互是对谁、为哪件事登录的”。
2. re-auth 要有明确的 detach / resume contract
重授权之后最怕的是半开连接和幽灵状态:逻辑上你已经有新 token,运行时里却还挂着旧 transport、旧 listener 或旧 stream。
所以 runtime 最少要明确三件事:
- 旧 transport 何时算失效;
- 新 transport 如何接管;
- 原请求如何重放,哪些请求禁止自动重放。
没有这三条,step-up 一多,系统迟早会出现“UI 显示已授权,但 agent 永远跑不动”的灰故障。
3. scope 升级要作为状态累积,而不是一次性弹窗
MCP draft 明说 client 应该把之前的 scopes 与新 challenge 的 scopes 做 union。这个细节很像权限系统里的 capability accumulation:
- 如果只按当前 challenge 覆盖 scope,旧权限可能丢失;
- 如果每次只增一个 scope,却不记录历史,系统很难解释为什么 token 现在拥有这组权限;
- 如果没有 retry limit,坏配置会把 agent 推进无限重授权循环。
对 OPC 来说,这和 tool approval、sandbox capability、browser handoff 其实是一类问题:权限提升不是一个 UI 动作,而是一条带审计、带边界、带恢复语义的运行时状态迁移。
常见误区
误区一:拿到 token 就代表授权完成
不对。拿到 token 只是开始。真正关键的是:这个 token 是否仍然绑定到正确的 resource、scope 集合和原始请求语境。
误区二:重连成功就等于恢复成功
也不对。恢复成功意味着原协议状态机继续往前走,而不是“socket 又开了”“client 又初始化了”。如果旧 transport 没 detach、原请求不能安全重试、scope 信息丢了,本质上都不算恢复。
误区三:OAuth 问题属于集成层,不影响 runtime 核心设计
恰恰相反。只要 Agent runtime 允许在运行中接第三方 MCP server,OAuth challenge、step-up、retry、detach、metadata preservation 就已经是 runtime contract 的一部分。
我对这条信号的核心判断
Cloudflare 这次最有价值的地方,不只是修复了两个 MCP OAuth bug,而是把一个经常被忽略的现实钉死了:MCP 互操作性的 hardest part,往往不是 JSON-RPC message 能不能发出去,而是授权挑战、transport 生命周期和恢复语义能不能一起成立。
这意味着下一阶段做 Agent 平台,MCP 接入层至少要从“能连上 server”升级到“能在真实权限变化下稳定恢复”。真正成熟的实现,应该把以下几件事放进同一个测试与运行时合同里:
- challenge metadata 保留;
- scope step-up 累积;
- transport detach / reconnect;
- 原请求安全重试;
- 官方 conformance 持续回归。
谁先把这些做成基础设施,谁的 MCP 集成才更像可托管的软件系统,而不是幸运时能跑通的 demo。
留给 Dewey 的问题
如果 OPC 接下来要把更多外部 MCP server 纳入统一 runtime,你会更想先固定哪一层契约:challenge state 的持久化格式,还是 re-auth 之后的请求重放/幂等策略?为什么?