OpenWork 开源桌面应用的远程 MCP 授权:文档、核验报告与测试

2026-08-04

本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。

给远程 MCP 服务做授权,真正的难点不是跑通 OAuth,而是把「跑通那一次」变成可复现、可排查、可交接的东西。 OpenWork(一个把技能、MCP 连接与外部服务打包成可共享「能力」的开源桌面应用,仓库地址 https://github.com/different-ai/openwork)里恰好摆着三份能对照着读的材料:一份写给管理员的流程文档 docs/external-mcp-oauth.md,一份带截图和断言的一致性核验报告 docs/pr-proof/mcp-oauth-conformance/report.md,还有一组把真引擎和 mock provider 都拉起来的端到端测试 apps/server/src/mcp.oauth-flow.e2e.test.ts。三份材料分别对应「怎么用」「证明它通了」「改动后还通不通」,凑在一起就是一套完整的授权工程教材。

站内已有几篇通用文:MCP 授权加固讲的是通用加固手法,MCP 授权过期怎么办讲失效与续期的处理套路,MCP 安全边界讲权限该收在哪。本篇不重复这些结论,只做一件事——把 OpenWork 这个真实仓库拆开,看这些原则最后落到了哪几个文件、哪几个字段上。你可以边读边打开仓库对照。

先说清许可证,因为下面会反复提到团队控制面。这个仓库的许可证是分层的:/ee 目录下的内容按 ee/LICENSE 定义的 Fair Source 许可证(文件开头写的是 Functional Source License, Version 1.1, MIT Future License,Copyright 2026 Different AI Inc),其余部分才是 MIT(Copyright 2026 Different AI)。本文提到的 ee/apps/den-api/ 就在前者范围内。能不能商用、能不能改,一律以许可证原文为准,本文不提供法律意见。

一、麻烦在哪:远程 MCP 授权不是「填个 token」

本地 MCP 服务用 stdio 起进程,权限问题基本等于文件系统权限问题。远程 MCP 换成了另一套东西:服务端会用 401 挑战告诉你需要 OAuth,你得先做协议初始化、再读 RFC 9728 的受保护资源元数据、找到授权服务器、判断它支不支持 PKCE 和刷新令牌、决定用哪种客户端注册方式、选出一组 scope,最后才走到那个所有人都熟悉的浏览器跳转。

这条链上任何一环出错,用户看到的都是同一句「连接失败」。OpenWork 的做法是先把链条本身切开。packages/enterprise-mcp-client/src/contracts.ts 里定义了两级阶段枚举:操作阶段 EnterpriseMcpOperationPhaseconfigurationrequirements-discoveryconnection-handshakeauthorization-callbackprotocol-initializetool-discoverytool-executionshutdown 八个;请求阶段 EnterpriseMcpRequestPhase 更细,把 oauth-resource-discoveryoauth-server-discoveryoauth-client-registrationoauth-token-exchangeoauth-token-refresh 和 MCP 侧的 mcp-initializemcp-tool-discoverymcp-tool-execution 分得清清楚楚。errors.ts 只做两件事:把这两个枚举导入进来挂到错误对象上,再按操作阶段映射出错误码常量,比如需求发现阶段对应 MCP_REQUIREMENTS_DISCOVERY_FAILED。同一个文件里还给每个阶段配了一句人话标签,拼进错误消息里,让消息本身就说清「卡在哪一步、在请求谁」。

这件事的价值不在代码优雅,在于排查成本。当你手上只有一个「失败」和一个阶段码,你至少知道该去看 provider 的元数据端点还是看自己的出网策略。contracts.ts 里的诊断事件 EnterpriseMcpDiagnosticEvent 还单独留了一种 credential-invalidation,带 httpStatusinvalidToken 字段——凭据失效是一类需要单独统计的事件,而不是普通失败。

二、发现阶段:先把要求摸清楚,再决定连不连

docs/external-mcp-oauth.md 里有一句话值得单独拎出来:发现是无副作用的,它不创建连接、不注册 OAuth 客户端、不打开浏览器、不保存凭据。它只报告 MCP 初始化结果、受保护资源元数据、授权服务器列表、PKCE 与刷新支持情况、可选的注册方式、scope、可见工具,以及那些标准元数据证明不了、只能由管理员去确认的事项。

这个结构在 packages/enterprise-mcp-client/src/contracts.tsEnterpriseMcpConnectionRequirements 上有对应的类型。它的 status 是四态:readymanual_action_requiredunsupportedunreachable。工具可见性也是三态:available_without_authrequires_authunavailable。除此之外还有两个数组——manualRequirementswarnings,前者每一项带 codelabelreasonrequired,后者只带 codemessage。差别在 required:手工要求分得清「必须做」和「建议做」,警告则一律只是提示。

把「机器能证明的」和「人得去确认的」分成两个字段,比含混地返回一个布尔值有用得多。真实环境里挡住你的往往是代理、私有 CA、DNS、防火墙、服务网格出口这类东西,标准元数据对此一无所知,只能落到人身上。

授权真正开始时,注册方式按文档给出的优先级依次尝试:管理员预先注册的客户端优先;其次是授权服务器广告了客户端元数据 URL(CIMD)时走 CIMD;再次是服务器广告了注册端点时走动态客户端注册;都不行就返回一个「需要配置」的结果,附带缺失的手工步骤。scope 的规则也写死了:401 挑战里要求的 scope 是锁定的,管理员可以另选广告出来的可选 scope 并在之后编辑;两边都没给 scope 时,回退到 provider 广告的 scopes_supported 集合,理由是有些 provider 会拒绝不带 scope 的授权请求;已配置的 scope 集合优先级更高。offline_access 只在该 scope 与刷新令牌支持同时被广告时才请求。

这几条不是通用最佳实践,是被具体 provider 的脾气逼出来的兼容决策。你自己接远程 MCP 时会遇到同一批坑,可以直接当参考。关于 scope 该收到多紧,可以配合最小权限设计一起看。

三、回调与注册:一个部署级回调,和它必须背的历史包袱

公共 URL 全部从 DEN_API_PUBLIC_URL 这个配置派生,文档明确写了请求的 host 头不能替代它。派生出三个地址:共享回调 <DEN_API_PUBLIC_URL>/v1/mcp-connections/oauth/callback、老的按连接回调 <DEN_API_PUBLIC_URL>/v1/mcp-connections/<connection-id>/connect/callback,以及公开的客户端元数据文档 <DEN_API_PUBLIC_URL>/oauth/client-metadata.json。元数据文档本身不含密钥,把 OpenWork 描述成一个 web OAuth 客户端;每个自托管部署都有自己的一套地址。

有意思的是新老并存的处理。新连接一律用部署级共享回调,已有连接则保留自己行上存的回调模式,包括那个更老的按连接回调。代码里这个模式是个三值枚举,在 ee/apps/den-api/src/routes/org/mcp-connections.ts 里以 z.enum(["shared-v1", "isolated-v1", "legacy-v1"]) 出现,缺省值取 legacy-v1。回调模式被持久化,并且绑进签名过的 OAuth state:共享路由只接受共享回调的事务,按连接路由只接受属于该连接的事务,文档写明这条路由认的签名模式是 legacy-v1。已经在途的一代事务在它原本的十分钟生命周期内继续绑定老验证器。

这套设计的取舍很清楚:宁可永久背着一个 legacy 分支,也不让老连接在重连时换掉已经注册到 provider 那边的 redirect URI。文档甚至明说仪表盘不提供迁移或回滚操作。代价是,删除并重建连接会产生一个全新的共享回调注册,可能一并抹掉访问授予、每个成员的授权状态,以及插件或市场绑定。

还有两条清账规则:改动 MCP 服务器标识或所选 issuer 会清掉令牌和待处理的授权状态;换 issuer 还会额外清掉已保存的客户端注册,这样密钥就绝不会被发给一个新选的 issuer。

四、三份材料各管一段

组成部分它负责什么仓库位置你什么时候会碰到它
授权流程文档公共 URL 派生、注册优先级、scope 规则、回调路由、故障排查清单docs/external-mcp-oauth.md配部署公共地址、遇到 redirect URI 不匹配时
通用 MCP 客户端包发现、授权、刷新、工具发现与工具调用的统一实现,阶段与错误码定义packages/enterprise-mcp-client/src/(含 requirements-discovery.tsoauth-provider.tserrors.tscontracts.ts想知道某个失败属于哪个阶段、要接自己的出网策略时
一致性核验报告把「发现 → 共享回调授权 → 工具可用」跑成带截图与断言的证据docs/pr-proof/mcp-oauth-conformance/report.md(流程脚本 evals/flows/mcp-oauth-conformance.flow.mjs需要向他人证明这条链路端到端真的通
端到端测试拉真引擎与 mock provider,覆盖首授权、静默刷新、登出三段apps/server/src/mcp.oauth-flow.e2e.test.ts改动授权相关代码之后
mock OAuth MCP 服务器可控的假 provider,可关动态注册、可强制刷新轮换、可作废访问令牌scripts/mock-oauth-mcp-server.mjs想复现某个 provider 的怪癖时
团队控制面侧的连接路由持久化回调模式、校验签名 state、约束允许的 redirect URIee/apps/den-api/src/routes/org/mcp-connections.tsee/apps/den-api/src/mcp/oauth-client-policy.ts用团队版共享连接时(注意 /ee 许可证不同)

核验报告那份读起来最像「给人看的证据」。它一共四帧截图,每帧都配了一段旁白脚本,其中三帧还挂着可见文本断言(第三帧只留了截图与旁白):第一帧证明管理员只输入 URL 就能看到「需要 OAuth 认证」「推荐动态注册」「可用 scope 含 mcp:read」和「需要管理员确认的事项」,且此时什么都还没保存;第二帧证明动态注册加 PKCE 在真实浏览器窗口里走完并从部署级回调返回;第三帧证明连接卡片上显示的回调是部署级的、不含连接标识;第四帧证明授权结果一路传到了面向 agent 的 MCP 表面——工具目录里出现了那个 mock_echo 工具,同一个连接可以经由 search_capabilitiesexecute_capability 直接使用(这两个工具的名字在仓库 README 里也有,README 把它们描述为 OpenWork MCP 对外暴露的两个工具)。

端到端测试那份则是「给 CI 看的证据」。它用 scripts/mock-oauth-mcp-server.mjs 起一个假 provider(环境变量 AUTO_APPROVE=1STRICT_REFRESH_TOKENS=1),再起一个真的 opencode sidecar 引擎,测试自己扮演浏览器去访问授权 URL 并跟随 302。断言点很硬:授权 URL 上 code_challenge_method 必须是 S256,回调带回的 state 必须与请求时一致,mock 侧的请求日志里 POST /registerGET /authorizePOST /tokenPOST /mcp 四条必须全都出现(断言查的是「包含」,不锁死先后顺序),令牌必须落到 mcp-auth.json 且访问令牌以 mock-access- 开头。

第二个用例专门测刷新:先记下当前日志长度,调用 mock 的 /admin/expire-access-tokens 把服务端所有活的访问令牌作废(刷新授予仍然有效),再让引擎重新注册这个 MCP 触发一次认证往返。它断言的不只是「最后连上了」,还包括这次恢复必须用 grant_typerefresh_token 的换取,且之后不能再出现 /authorize/register——也就是说,静默刷新不许偷偷退化成重新走一遍浏览器授权。轮换后的新旧令牌还必须不相等,且已经落盘。第三个用例测登出:删除授权后,mcp-auth.json 里该条目必须消失。

五、边界与代价:这套设计放弃了什么

凭据集中保管本身就是暴露面。 文档开头就写明,令牌、刷新令牌、客户端密钥、PKCE 验证器和待处理的授权事务都加密留在服务端,不进入 agent 引擎。好处是 agent 侧拿不到长期凭据;代价是控制面成了高价值目标,且团队管理员天然能看到连接状态、scope 集合和工具目录。你把一个办公套件账号授权进去,就等于承认这套控制面在你和 provider 之间。评估时该问的是「谁运维这个部署」,而不是「它加不加密」。

没有运行时开关。 文档写得很直白:服务端用 @openwork/enterprise-mcp-client 做发现、授权、刷新、工具发现与工具调用,没有部署级或工作区级的运行时切换。统一是好事,但也意味着遇到某个 provider 的边缘行为时没有逃生口,只能改包。

出网安全不归这个包管。 contracts.tsEnterpriseMcpClientOptions.fetch 是必填项,注释直接写明:组合根拥有 SSRF、DNS 重绑定、代理、TLS、重定向、响应体积和密钥转发策略。这是干净的分层,但也是明确的甩锅——你要是照着这个包自己搭一套,这些全得自己接上,没接就是裸奔。

私有 scheme 回调基本不支持。 ee/apps/den-api/src/mcp/oauth-client-policy.ts 里的常量 MCP_OAUTH_PRIVATE_USE_REDIRECT_URIS 是一个白名单,目前只放了 Cursor 那一条。注释解释说 MCP 规范把 OAuth 回调限制在 HTTPS 或 HTTP loopback,个别原生客户端只支持 RFC 8252 的私有 scheme,才为它们开口子,并靠强制 PKCE S256 缓解回调被截获的风险。换句话说,别的原生客户端不在这条路上。

它明确不管的事。 provider 侧回调配置的传播延迟(文档只提醒「有些 provider 需要一小段时间生效」);标准元数据证明不了的网络与管理员工作(落到 manualRequirements);老连接的回调模式迁移(明说不提供)。另外,OAuth 日志和支持数据只有阶段码与错误码,令牌、密钥、授权码、签名 state、PKCE 验证器和 URL 查询串一律不记——排查时你拿不到原始值,只能靠阶段定位。

顺带说清楚,命名上 OpenWork 指的是这个开源桌面应用项目,跟同名的职场点评网站、以及「开放工作」这种泛指没有关系。仓库 README 把自己定位成 macOS、Windows、Linux 上 Claude Cowork 与 Codex 的开源替代,这是项目自己的说法,不是本文替它下的判断。

六、上手与避坑清单

别让公共地址靠请求头猜。 会踩是因为本地开发时 host 头恰好就是对的,上了反向代理就开始随环境漂移,而 OAuth 的 redirect URI 必须逐字符匹配。避法是把 DEN_API_PUBLIC_URL 当成部署的硬前提,注册到 provider 那边的回调直接从它派生,改动前先确认 provider 侧已经生效。

别指望「删了重建」能修问题。 会踩是因为这是所有人处理连接异常的肌肉记忆。文档写明重建会产生新的共享回调注册,可能连带移除访问授予、每个成员的授权状态以及插件或市场绑定。避法是先按故障清单逐条对:配置缺失、issuer 不匹配、需要重新授权、网络信任、新增权限——每一条对应的动作都不一样。

换 issuer 之前先接受「令牌会没」这件事。 会踩是因为管理员通常把选 issuer 当成一个下拉框操作。实际它会清掉令牌和待处理授权状态,换 issuer 还会清掉客户端注册(防止把密钥发给新 issuer)。避法是把它当成一次重新授权来安排时间窗口,而不是顺手改。

scope 留空不等于省事。 会踩是因为很多人以为不传 scope 最保险。实际有的 provider 会直接拒绝无 scope 的授权请求,所以会回退到 provider 广告的全集,可能拿到超出需要的权限;反过来,只授一部分 scope 又可能让你停在 provider 的验证页上。避法是显式挑选,把挑选结果保存下来,别依赖回退。

别把跑绿的 CI 当成测过了。 会踩是因为那组端到端测试在找不到 opencode sidecar 二进制时会自动跳过(findSidecar() 返回 null 就用 describe.skip),CI 上照样是绿的。避法是在流水线里显式确认这组用例执行过,而不是只看总结果。

只测首次授权是不够的。 会踩是因为刷新失败要等到访问令牌过期才暴露,通常在生产。避法照抄仓库的做法:用 mock 的作废端点强制制造 401,然后断言恢复走的是刷新授予,且不能出现新的 /authorize/register。这条断言比「最终连上了」有价值得多。

排查时别指望日志里有令牌。 会踩是因为习惯了 grep 原始值。这里日志刻意去掉了敏感字段。避法是先用阶段码把范围缩到某一段——是资源发现、服务器发现、客户端注册、令牌换取还是令牌刷新,再去对应端点上验证。

结尾:接下来读哪个文件

这三份材料的关系可以这样收:文档告诉你规则,报告证明规则在真实浏览器里成立,测试保证规则在下一次改动后还成立。缺任何一份,另外两份的说服力都会掉一截。

如果你要接一套自己的远程 MCP 授权,建议按这个顺序读仓库:先看 packages/enterprise-mcp-client/src/contracts.ts,把要建模的状态和阶段抄清楚;再看 errors.ts,确认自己的错误分类够不够细;然后看 apps/server/src/mcp.oauth-flow.e2e.test.ts,照着它的断言列表检查自己的测试少了哪几条;最后回到 docs/external-mcp-oauth.md,把故障排查清单改写成你自己 provider 的版本。至于 ee/ 下那部分团队控制面代码,读之前先把 ee/LICENSE 看完,用途边界以许可证原文为准。

本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 开源桌面应用的 MCP 服务端:两个工具收敛整套能力OpenWork 能力市场架构:开源桌面应用如何把技能发布并指派到人

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。