OpenWork 开源桌面应用的 MCP 服务端:两个工具收敛整套能力

2026-08-04

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

OpenWork 这个开源桌面应用最值得抄的一处工程决策,是它没有把自己的能力目录整个铺给 Agent,而是在 /mcp/agent 这个端点上只注册了两个工具——搜索一个、执行一个,其余上百个操作在这个端点上根本不可单独调用。 仓库里 ee/apps/den-api/src/mcp/agent.ts 的注释把动机说得很直白:富端点 /mcp 保持原样、每个目录操作都单独注册,而 /mcp/agent 是给另一类消费者准备的——OpenCode、Claude Code、Codex 这类 harness 实际看到的那一面,它看到的工具数量从注释里写的 ~129 个降到 2 个。

先做个分工说明,免得你在站内绕路:想从零写一个 MCP 服务端,看 MCP 服务端开发入门;想理解协议转向无状态之后服务端该怎么改,看 MCP 变成无状态了;想弄清长任务在协议层的归属变化,看 Tasks 被移出核心协议;本篇只盯一件事——OpenWork 把「一整套能力」压成「搜索 + 执行」这两个工具时,字段、失败语义和权限边界各自付了什么代价。

一、被撑爆的从来不是能力,是上下文

你接过五六个 MCP 服务端就会有体感:每个服务端十几个工具,工具描述又不敢写短,一轮对话还没开始,工具定义已经吃掉可观的一块上下文。更麻烦的是模型选错工具的概率随目录变长而上升,这件事在 MCP 工具数量该怎么控 里讲过机制。

OpenWork 面对的目录比一般项目大。仓库文档 packages/docs/cloud/run-in-the-cloud/cloud-mcp.mdx 列出的可搜索范围包括配置对象、连接器、插件、市场、技能、worker、成员、角色、团队、模型供应商,以及组织接入的外部 MCP 工具。这些东西如果按传统做法一个工具一个 schema 铺出去,任何客户端都撑不住。

它的处理方式是拆端点而不是砍功能。/mcp 保留完整目录,脚本和管理工具仍然可以按名字直接调某个已知操作;/mcp/agent 换成窄接口,让 harness 只能通过搜索发现、通过一个泛化的执行工具调用。两条路走的是同一份目录、同一条执行路径——agent.ts 的注释里明确写着「no new auth, no new policy, no new execution logic」,执行仍然落到 invoke.tsinvokeMcpOperation。这一点很关键:收敛只发生在暴露面,不发生在权限层,否则窄接口就成了绕过策略的后门。

二、两个工具各自长什么样

search_capabilities 这个名字定义在 ee/apps/den-api/src/mcp/search.ts(常量 SEARCH_CAPABILITIES_TOOL_NAME),execute_capability 定义在 agent.ts(常量 EXECUTE_CAPABILITY_TOOL_NAME)。

搜索工具的入参是三个:query 是关键词,limit 限定返回条数(代码里限制在 1 到 20,缺省 5),type 是来源过滤器,枚举值为 allapiadminmcpmarketplaceskillssearchCapabilitySourceFilter 把这个枚举展开成五个布尔开关,搜索阶段按开关分别去查原生 API 目录、平台管理员能力、内建技能、组织接入的外部 MCP、市场插件能力,最后合并排序截断。

执行工具的入参是五个:name(必须是搜索返回的精确名字)、schemaDigestpathquerybodyschemaDigest 带了正则约束 ^sha256:[a-f0-9]{64}$,用途是让外部 MCP 能力在 schema 漂移时把问题报成建议而不是硬拦。

两个工具的 annotations 是刻意对称写反的:搜索侧 readOnlyHint: truedestructiveHint: falseidempotentHint: true;执行侧 readOnlyHint: falsedestructiveHint: trueidempotentHint: false。两者的 openWorldHint 都是 true。这组标注不是装饰——客户端拿它来决定要不要弹确认、要不要允许自动重试。

服务端本身还挂了一段 instructions(AGENT_MCP_INSTRUCTIONS),第一句就是「这个连接刻意只暴露两个工具」,后面跟着一条很实用的规则:在断定某个能力不存在之前,先用 2 到 4 个关键词变体搜一遍。窄接口的代价就在这——模型不能再靠扫工具列表建立世界观,只能靠搜索,所以必须在 instructions 里把搜索的重试纪律写死。

三、搜索结果要携带多少信息,执行侧才不用猜

这是整个设计里最见功夫的一段。search.ts 的注释把理由写清楚了:在富端点上,搜索结果只是参考信息,因为 harness 可以直接调那个工具;在 /mcp/agent 上,搜索结果是唯一的发现途径,所以每条匹配必须自带足够的形状,让调用方不靠猜就能拼出一次合法的 execute_capability

CapabilityMatch 因此带上了 pathParamsqueryParamshasBody 三个结构字段,JSON 变更类操作还会附上 bodySchema(来自 OpenAPI 原始 schema)。外部 MCP 的匹配则另走一套:带 argumentsSchemaschemaDigest,以及 invocation.argumentsField,后者的值被类型收窄成字面量 "body"——等于用类型告诉调用方「参数往 body 里塞」。

排序逻辑也在 search.ts 里,scoreText 的权重是:查询词命中工具名 token 加 5 分,前缀互为包含加 3 分,命中摘要加 2 分,命中额外 token(路径)加 1 分。工具名先经 tokenizeToolName 把 camelCase 拆成小写词,所以搜 organization 能命中 getOrganizationscompareCapabilityMatches 在分数之前先排一个优先级:kindconnection_status 的匹配永远排在前面。这条规则的意思是,当某个连接坏了,「连接坏了、该谁去修」这条信息比任何一条正常能力都更该被模型先看到。

组成部分它负责什么对应仓库位置你什么时候会碰到它
两个工具的注册与分派注册 search/execute、按能力名依次分派到管理员、内建技能、外部 MCP、原生 API、市场五条执行路径ee/apps/den-api/src/mcp/agent.ts想知道某个能力名到底被谁执行时
匹配打分与结构字段定义 CapabilityMatch、分词打分、来源过滤器ee/apps/den-api/src/mcp/search.ts搜不到想要的能力、怀疑排序有问题时
外部 MCP 能力接入解析 mcp: 前缀的能力名、探测连接、生成覆盖度提示ee/apps/den-api/src/mcp/external-capabilities.ts组织接了第三方 MCP 但搜索结果不全时
桌面侧 MCP 清单与封禁诊断清点 project/global/runtime 三层 MCP 配置,诊断工具被策略封掉的原因apps/server/src/mcp.ts桌面应用里连接显示异常、工具消失时
云连接的健康与对账路由暴露 health、engine-refresh、reconcile 三条 HTTP 路由apps/server/src/routes/cloud-mcp.ts排查桌面端与云端连接不同步时
客户端接入与协议细节文档端点地址、各客户端接入步骤、OAuth 与令牌刷新说明packages/docs/cloud/run-in-the-cloud/cloud-mcp.mdx第一次把它接进自己的 Agent 客户端时

四、失败语义:窄接口必须把「下一步做什么」写进错误里

工具少了以后,模型犯错的种类反而更集中,主要就两类:名字错了、参数错了。agent.ts 对这两类给了明确的机器可读回包。

名字错了返回 unknown_capability,消息里直接指示「调用 search_capabilities 找一个有效名字」。参数错了走 invalid_capability_arguments,错误载荷里带 issues 数组(每项含 pathkeywordmessage)、sameArgumentsRetryable: false,以及 retry 对象——retry.action 的取值是 correct_argumentssearch_capabilities。翻译成人话:不许拿同一份参数原样重试,要么改参数,要么回去重新搜。

外部 MCP 的 schema 校验采取了一个我觉得值得单独记一笔的取向:本地发现参数与供应商声明的 schema 对不上时,OpenWork 仍然会把调用打给下游,把不匹配以 schemaGuidance 的形式作为建议附在结果旁边。instructions 里配套写明——供应商成功了就接受结果,别只因为这条警告去重试;供应商失败了才用它来改参数。这是一个明确的权衡:宁可放过一次本地误判,也不要让本地 schema 陈旧把一条本来能用的能力堵死。

执行侧还有一层超时预算。EXECUTE_CAPABILITY_TIMEOUT_MS 定为 180000 毫秒,executeCapabilityWithBudgetPromise.race 把真实调用和超时结果赛跑,超时返回 capability_timeout。它的错误文案里有一句很克制的话:告诉用户服务慢了、让他缩小请求范围,不要让用户去重新配置或重连。这类「不要把下游慢误报成连接坏」的判断,散落在整份 instructions 里好几处。

搜索侧也有对应的诚实机制。外部连接太多探不完时,externalMcpSearchCoverageHint 会生成一条提示,写明这次检查了多少个符合条件的连接、结果可能不完整、建议用连接名收窄查询再搜一次。返回不完整的结果而不声明,才是让 Agent 得出错误结论的根源。

五、桌面这一侧:连接是被诊断出来的,不是被假设的

apps/server/src/mcp.ts 是另一半故事。桌面应用里这条云连接有个固定名字:apps/server/src/cloud-mcp-health.ts 里的常量 OPENWORK_CLOUD_MCP_NAME 取值 openwork-cloud,同文件的 OPENWORK_CLOUD_EXPECTED_TOOLS 把引擎里那两个工具的完整 id 钉成 openwork-cloud_search_capabilitiesopenwork-cloud_execute_capability;诊断这一侧的 mcp.ts 自己另存了同样两个 id(常量 OPENWORK_CLOUD_DIAGNOSTIC_TOOL_IDS)。同一份字符串在健康检查与诊断两处各留一份,读代码时别把它们当成一个地方。

这个文件里最大的一块代码,是在回答一个很具体的运维问题:这两个工具为什么不见了。它把用户可能写下的禁用写法枚举了个遍——tools.deny 数组、tools 记录式布尔、permissionpermissions 的标量式、规则数组式和对象嵌套式,然后用 permissionCandidates 为每个工具 id 生成一串候选键(tool.<id>mcp.<name>mcp.<name>.*mcp:<name>:*mcp.* 等),逐一用 minimatch 匹配,命中就记一条 McpToolDeny,带上来源(项目配置还是全局配置)、写法风格、命中的 pattern 和被命中的工具 id。项目级 allow 能否覆盖全局 deny,也单独写了 filterGlobalDeniesOverriddenByProjectAllows 来判定。

listMcpFromRuntimeSnapshot 则把 MCP 条目分成三个来源:config.globalconfig.projectconfig.remote,最后一个是运行期动态注册的。代码注释解释了顺序的原因:运行期 MCP 是启动后动态 POST 给引擎的,可以盖掉静态条目。而诊断专用的 inspectMcpLayersFromRuntimeSnapshot 走的是另一条路——它返回所有层和冲突信息(collisions),但注释明确写着它不宣称哪一条生效,理由是这个答案依赖生命周期,去观测它会唤醒一个冷的引擎。诊断代码克制到这个程度的项目不多见。

apps/server/src/routes/cloud-mcp.ts 则只开了三条路由:GET /workspace/:id/mcp/openwork-cloud/healthPOST .../engine-refreshPOST .../reconcile。两条写操作都要求 collaborator 客户端作用域并先做可写检查;assertStrictBody 硬性规定 body 里的 name 只能是 openwork-cloud,别的名字返回 invalid_mcp_name。engine-refresh 允许空 body,但格式错的 JSON 一律硬 400 invalid_json,注释里写明「never silently ignored」。

六、这套设计放弃了什么

先说清授权与数据流向这件事,因为它是真实代价。这类工具会在你机器上装桌面应用,代管模型供应商凭据与第三方服务授权,可能连上 Gmail、日历、云盘这类办公套件,还可能挂在团队控制面下面。凭据集中保管意味着暴露面也集中:一个令牌泄漏影响的不是一个服务,是这个组织授权给你的整片能力。文档写明可用性受组织成员身份、角色、策略和暴露白名单四层约束,也就是说企业侧对你能搜到什么、能执行什么是有控制权的,这点在接入前应当先和管理员对齐。

许可证也必须说清楚:这个仓库是分层的。根目录 LICENSE 写明 /ee 目录下的所有内容按 ee/LICENSE 定义的 Fair Source 许可证授权(该文件的标题是 Functional Source License, Version 1.1, MIT Future License),其余部分才是 MIT(Copyright 2026 Different AI)。而本文拆的 search_capabilities/execute_capability 服务端实现正好落在 ee/apps/den-api/ 下面,团队控制面的能力也大多在 ee/ 里。所以不要把它笼统当成「MIT 开源」来做商用或二次分发的决策,能不能商用、能不能改,以许可证原文为准,本文不提供法律意见。

设计本身放弃的东西同样具体:

其一,放弃了工具级的静态可发现性。客户端界面上只能看到两个工具,用户无法在连接面板里浏览「我到底能干什么」,只能靠问。对喜欢先看清全貌再动手的人,这是体验倒退。

其二,放弃了工具粒度的权限控制。客户端侧的 allow/deny 只能作用到这两个名字上,要么全放要么全禁,真正的细粒度授权全部退到服务端策略里。你在客户端配置里写规则时,能封的只是整条连接。

其三,明确不管的部分。文档写明这个端点刻意不暴露认证内部机制、仅管理员可用的系统路由、webhook、API key 的创建与删除,以及任何会返回凭据的端点。它也不是本地工具的替代品——文档里专门提醒 app.openworklabs.com/api/den 是第一方桌面流程用的内部同源代理,不要粘进外部 MCP 客户端。

其四,客户端成熟度并不均等。仓库那张客户端支持状态表里,只有 OpenCode 标为 Verified(原生远程 MCP OAuth 通过了端到端实现测试),Claude Code、Claude Desktop、Codex、Cursor、ChatGPT Desktop、VS Code、Windsurf、Zed、Gemini CLI 全部标为 Setup only,表格逐行注明原生验证尚未完成;表格末尾还给所有其它客户端留了一行,条件是必须支持远程 Streamable HTTP 的 MCP 服务端与 OAuth。想接的话,先看这张表再定预期。

顺带一提,仓库 README 把自己定位成「Claude Cowork 和 Codex 的开源替代」——这是项目自己的说法,不是本文对它的评价。

七、上手与避坑清单

把两个工具名当成契约,别在客户端里按引擎 id 写规则。 会踩是因为服务端注册的名字是 search_capabilities,而引擎里看到的是加了连接名前缀的 openwork-cloud_search_capabilities,两者不是同一个字符串。避法:写权限规则前先确认你面对的是哪一层,桌面侧的诊断逻辑同时认工具 id 和 mcp.<name> 这类连接级候选键,规则写在连接级更稳。

别在参数不合法时原样重试。 会踩是因为多数 Agent 循环的默认重试策略就是把上次的调用再打一遍。避法:认准回包里的 sameArgumentsRetryable: falseretry.action,前者出现就必须改参数或回去搜,硬重试只会白烧一轮上下文。

别把下游连接器的失败当成 MCP 连接失败。 会踩是因为报错文案容易混,用户第一反应就是断开重连。instructions 里专门写了一条:一次成功的 search_capabilities 就证明这条云连接是被授权的。避法:先看匹配里有没有 kindconnection_status 的条目,按它给出的 action 去对应界面修,而不是重连整条 MCP。

搜索一次搜不到不等于没有。 会踩是因为打分是基于 token 的字面匹配,同义词不会自动扩展,搜「邮件」和搜「gmail」结果可能完全不同。避法:照 instructions 的要求换 2 到 4 组关键词,必要时用 type 参数把范围收窄到某一类来源。

外部连接多的时候留意覆盖度提示。 会踩是因为探测是有预算的,连接多就会截断,而截断后的结果看上去和完整结果没区别。避法:读返回里的 hint 字段,看到覆盖度提示就带上连接名再搜一次。

接入前先确认客户端支持远程 Streamable HTTP 与 OAuth。 会踩是因为不少客户端只支持本地 stdio 形态的 MCP。文档写明不支持 OAuth 远程 MCP 的客户端目前接不上。避法:先查那张客户端支持状态表。

令牌里钉着组织。 会踩是因为你在桌面应用里切了组织,外部客户端的旧令牌不会跟着变。避法:按文档的做法先登出该 MCP 条目再重新认证;遇到 invalid_grant 也是同一套处理。授权过期这类问题的通用处置思路,可以参考 MCP 授权过期怎么办

收个尾

这套收敛法能成立,靠的不是「把工具数量减少」这个动作本身,而是三件配套的事同时做到了:搜索结果携带了足够拼出调用的结构信息,失败回包写清了下一步该做什么,以及执行路径没有因为换了暴露面就换一套权限。少任何一件,两个工具就会退化成一个让模型反复瞎试的黑盒。

想接着往下读,建议按这个顺序:先看 ee/apps/den-api/src/mcp/search.ts,它最短,把匹配结构和打分讲完了;再看 ee/apps/den-api/src/mcp/agent.tsregisterAgentMcpRoutes 的分派顺序,能看清一个能力名是怎么依次被试着解析成管理员能力、内建技能、外部 MCP、原生 API、市场能力这五类的,前一类不认才轮到后一类;最后看 apps/server/src/mcp.ts,那是排查「工具怎么不见了」时你唯一需要的文件。

本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 开源桌面应用的工作区模型:初始化建了什么、状态存在哪、导出如何拦住敏感文件OpenWork 开源桌面应用的远程 MCP 授权:文档、核验报告与测试

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