跳到主要内容

对外契约:9 个工具、MCP/JSON-RPC 与 CLI

30 秒导读: 这一章讲的是这个服务对外长什么样——它暴露 9 个工具(列应用、看状态、点击、拖拽、打字……),同一套工具有两个门:一个是给 AI agent 用的 stdio MCP 服务器(说 JSON-RPC 2.0),一个是给人/脚本用的 CLI 子命令。两个门后面共用同一个分发器 ComputerUseToolDispatcher。真正"怎么把点击落到屏幕上"是下一章 03-action-execution 的事;这里只讲契约与路由

本章定位:讲清"服务对外暴露面"。往上看整体是什么、往下钻动作引擎,见 index03-action-execution;每回合的 UI 快照见 01-state-snapshot;软件光标见 04-software-cursor


1. 先建立直觉:一个工具,两个门

假设你是一个 AI agent,想让 macOS 上的 TextEdit 打一行字。你不会直接去调 macOS 的无障碍 API——太底层。你想说的是一句结构化的话:"对 TextEdit 执行 type_text,内容是 hello"。

这个项目就是把这句话变成现实的中间层。它对外提供一组工具(tool),每个工具有名字、有参数 schema。你(或你的 MCP 客户端)按 schema 填参数、发调用,它执行、回结果。

同一组工具,开了两个入口:

入口给谁用说什么协议典型场景
stdio MCP 服务器AI agent / MCP 客户端JSON-RPC 2.0 over stdioCodex、Claude 等把它当 MCP 插件挂上
CLI 子命令人 / shell 脚本命令行参数 + JSON手动调试、写自动化脚本

两个入口最终都落到同一个分发器 ComputerUseToolDispatcher.callTool,所以工具行为完全一致。这是本章要讲清的骨架。


2. 顶层全景:一次调用的两条路径

先看两条入口如何汇流到同一个分发器,再逐段拆。

┌──────────────────────────────┐
AI agent ─stdin──► │ StdioMCPServer.handle(line) │ MCPServer.swift
(JSON-RPC) │ 按 method 路由: │
│ initialize / tools/list / │
│ tools/call / notifications │
└───────────────┬──────────────┘
│ tools/call

人 / 脚本 ─argv──► ┌──────────────────────────────┐
(open-computer-use │ ComputerUseToolDispatcher │ ComputerUseToolDispatcher.swift
call ...) │ .callTool(name, args) │
│ │ · 解析/校验参数 │
│ runOpenComputerUseCall│ · element_index 归一化 │
└──────────────►│ · switch name → service.xxx │
└───────────────┬──────────────┘

┌──────────────────────────────┐
│ ComputerUseService(下一章) │ → AX 动作 / 快照
└──────────────────────────────┘

怎么读:两个入口(上=MCP,左下=CLI)各自解析请求,最后都调用 dispatcher.callTool。分发器再把 9 个工具名 switchComputerUseService 的对应方法上。"落地"的活在 service 里,本章不展开

部件一句话职责:

部件干什么在哪
ToolDefinitions.all9 个工具的 schema + annotations 清单ToolDefinitions.swift:32
StdioMCPServerJSON-RPC 循环与方法路由MCPServer.swift:20
ComputerUseToolDispatcher参数解析 + 工具名分发ComputerUseToolDispatcher.swift:39
parseOpenComputerUseCLICLI 参数 → 命令枚举OpenComputerUseCLI.swift:59
OpenComputerUseMain进程入口,按命令分发OpenComputerUseMain.swift:7

3. 9 个工具:schema 与注解

3.1 工具清单

所有工具定义在 ToolDefinitions.all(ToolDefinitions.swift:32),是一个静态数组。数组里正好 9 项,与对外承诺的 9 个工具逐条对应:click(:34)、drag(:53)、get_app_state(:68)、list_apps(:80)、perform_secondary_action(:86)、press_key(:99)、scroll(:111)、set_value(:125)、type_text(:138)——数组内按字母序排列(核对 ToolDefinitions.swift:33–148,grep -c name: 去掉结构体字段/init 两行后正好 9 个 name:)。

按"读 vs 改"分成两类:

工具作用必填参数注解类别
list_apps列出运行中/近 14 天用过的 app(无)readOnly
get_app_state拉当前回合的截图 + 无障碍树快照appreadOnly
click按元素索引或像素坐标点击appdefault
perform_secondary_action触发元素的次级无障碍动作app,element_index,actiondefault
scroll按方向翻若干页app,element_index,directiondefault
drag从一点拖到另一点(像素坐标)app,from_x/from_y/to_x/to_ydefault
type_text键入字面文本app,textdefault
press_key按键/组合键(xdotool key 语法)app,keydefault
set_value直接给可写无障碍元素赋值app,element_index,valuedefault

几乎每个工具都以 app(app 名或 bundle id)为必填——服务是"针对某个 app 的后台操作",所以每次调用都要指明目标 app。

3.2 readOnly vs default 注解

MCP 的 tool annotations 是给客户端的行为提示(比如决定要不要在执行前征求用户同意)。这里只有两套模板:

只读工具(readOnlyAnnotations(),ToolDefinitions.swift:173)——list_appsget_app_state 用它:

注解键含义
readOnlyHinttrue不改变环境,纯观察
idempotentHinttrue重复调用等价
destructiveHintfalse无破坏性
openWorldHintfalse不触达外部世界

其余 7 个"会动手"的工具用 defaultAnnotations()(ToolDefinitions.swift:166),只声明 destructiveHint:falseopenWorldHint:false——声明 readOnlyHint,也不声明 idempotentHint。区别的关键就在这:观察类明确打上 readOnlyHint:true,动作类留空,让客户端把它们当"有副作用"处理。

ToolDefinition.asDictionary(ToolDefinitions.swift:16)负责把定义序列化成 MCP 期望的字典;注意它有个细节:annotations 为空时不写进输出(if !annotations.isEmpty,ToolDefinitions.swift:23),避免发出空的 annotations: {}

3.3 一个工具 schema 长什么样

click 为例(ToolDefinitions.swift:33–51),它的 inputSchema 是标准 JSON Schema object:

{
"type": "object",
"additionalProperties": false,
"required": ["app"],
"properties": {
"app": {"type": "string", "description": "App name or bundle identifier"},
"element_index": {"type": "string", "description": "Element index to click"},
"x": {"type": "number"}, "y": {"type": "number"},
"click_count": {"type": "integer"},
"mouse_button": {"type": "string", "enum": ["left", "right", "middle"]}
}
}

两处约定值得记:additionalProperties: false(objectSchema,ToolDefinitions.swift:156)——多余字段不被接受;element_index 在 schema 里声明成 string,但分发器会容忍它是数字(见 §5)。mouse_buttonenum 限定三个取值(ToolDefinitions.swift:46)。


4. MCP 服务器:JSON-RPC 方法路由

4.1 stdio 循环

StdioMCPServer.run()(MCPServer.swift:27)是一个朴素的行循环:逐行读 stdin,跳过空行,把每行交给 handle(line:),拿到响应就带换行写回 stdout。一行一个 JSON-RPC 消息(JSON Lines)。

4.2 method 路由表

核心是 handle(line:) 里的 switch method(MCPServer.swift:49)。各方法处理如下:

method返回说明
initializeprotocolVersion + serverInfo + capabilities + instructions握手,MCPServer.swift:50
notifications/initializednil(不回)客户端就绪通知
notifications/turn-endednil,并重置软件光标见 §4.4
ping空结果 {}保活
tools/listToolDefinitions.all.map(\.asDictionary)吐出 9 个工具
tools/call分发到 dispatcher,回工具结果见 §4.3
其它非空 methodJSON-RPC 错误 -32601 Method not foundMCPServer.swift:96
method 为 nilnil(响应类消息,静默)MCPServer.swift:92

initialize 值得留意两点(MCPServer.swift:51–65):protocolVersion 硬编码为 "2025-03-26";instructions 塞进一大段 computerUseServerInstructions(MCPServer.swift:3)——那是给 agent 的使用提示(比如"每回合先调 get_app_state""优先用元素索引而非坐标点击")。这段字符串是被服务承诺的对外文本,agent 收到后会照做。

4.3 tools/call 与 isError 包装

tools/call 的处理(MCPServer.swift:83)很短:取 namearguments,调 dispatcher.callTool,把 result.asDictionary 包成 JSON-RPC result 返回。

关键在错误怎么表达。MCP 里工具级失败不是 JSON-RPC error,而是一个 isError: true 的正常结果。handle 用两个 catch 实现这层转换:

  • catch ComputerUseError(MCPServer.swift:98):把错误文案包成 ToolCallResult.text(..., isError: error.toolResultIsError),再当作正常 result 编码回去。toolResultIsError 恒为 true(Errors.swift:30)。
  • catch(兜底,MCPServer.swift:103):任意错误 → {content:[text], isError:true}

也就是说:JSON 解析失败才走真正的 JSON-RPC error(-32700,MCPServer.swift:42);工具执行失败一律降级成"成功返回一个 isError 结果"。区分二者是理解这个契约的要点。

结果结构由 ToolCallResult.asDictionary(ToolResult.swift:39)定:{content: [...], isError: bool},content 每项是 {type:"text", text}{type:"image", data(base64), mimeType:"image/png"}(ToolResult.swift:6/15)——get_app_state 的截图就走 image 项。

4.4 notifications/turn-ended → 重置光标

notifications/turn-ended(MCPServer.swift:69)是这个服务和宿主(Codex)之间的一个约定:宿主每个 assistant 回合结束时发这条通知。收到后服务器在主线程调 SoftwareCursorOverlay.reset()(MCPServer.swift:70),把上一回合遗留的软件光标叠层清掉——停动画、撤面板、清位置状态(SoftwareCursorOverlay.reset,SoftwareCursorOverlay.swift:273)。细节归 04-software-cursor

编码工具用的 encode(MCPServer.swift:141)带 .withoutEscapingSlashes,避免路径里的 / 被转义成 \/


5. 分发器:参数解析与 element_index 归一化

ComputerUseToolDispatcher.callTool(ComputerUseToolDispatcher.swift:46)是两条入口的共同下游。它做两件事:把松散的 JSON 参数取成强类型,再 switch name 调对应的 service 方法。

5.1 required / optional 取值助手

一组私有助手负责取参:

助手行为
requireString取非空字符串,缺则抛 missingArgument(ComputerUseToolDispatcher.swift:120)
optionalString / optionalBool / optionalDouble可选取值,缺则 nil(:128/132/166)
requireDouble / requireElementIndex必填版,缺则抛错(:158/146)

optionalBool(:132)与 optionalDouble(:166)都特意处理了 NSNumber——因为 JSONSerialization 把 JSON 的布尔/数字都解成 NSNumber,直接 as? Bool/as? Double 会漏,所以补了 NSNumber 分支。

5.2 element_index 的归一化

这是分发器最精细的一处。element_index 在 schema 里是 string,但 agent/脚本常填成数字 3(而非 "3")。为了兼容,normalizedElementIndexArgument(ComputerUseToolDispatcher.swift:3)把各种输入都收敛成"字符串或 nil":

"3" → "3" // 非空字符串原样
"" → nil // 空串视为未提供
3 (Int) → "3"
3.0 → "3" // 通过 normalizedElementIndexNumber
true → nil // 布尔被显式排除
3.5 → nil // 非整数拒绝
其它 → nil

两个防坑点:布尔在 Swift 里也是 NSNumber,所以先用 CFBooleanGetTypeID 把它挑出来判 nil(:13),不然 true 会被当成 1;normalizedElementIndexNumber(:27)要求 value 有限、是整数(rounded(.towardZero) == value)、且落在 Int 范围内,否则返回 nil——把 3.5 这种非法索引挡在外面。

分发器里 requireElementIndex/optionalElementIndex(:146/154)都走这个归一化。所以 click 允许 element_index 缺省(退化成坐标点击),而 scroll/set_value/perform_secondary_action 则要求它必填。

5.3 callToolAsResult:把 throw 变成 isError

除了会 throwcallTool,分发器还提供 callToolAsResult(ComputerUseToolDispatcher.swift:106):内部 try callTool,捕获后返回 ToolCallResult(isError:true) 而非抛出。CLI 用的就是这个——脚本侧不靠异常,而靠结果里的 isError 字段判断成败(见 §6)。这与 MCP 侧 §4.3 的两个 catch 是同一套语义,只是换了实现位置。


6. CLI 入口:单发与序列

6.1 命令集与解析

parseOpenComputerUseCLI(arguments:)(OpenComputerUseCLI.swift:59)把 argv 解析成 OpenComputerUseCLICommand 枚举(:3):

子命令枚举 case作用
(无参).launchOnboarding拉起权限引导 app
mcp.mcp启动 stdio MCP 服务器
doctor.doctor打印权限状态
list-apps.listApps列 app
snapshot <app>.snapshot打印无障碍快照(--show-full-text 不截断)
call ....call调工具(单发或序列)
turn-ended.turnEnded通知运行中的 MCP 进程"回合结束"
help [cmd] / version.help / .version帮助 / 版本

无参数直接 onboarding(:60);未知命令/选项抛 OpenComputerUseCLIError(:89–95),其 errorDescription 会自动附上对应子命令的帮助文本(:51)。

6.2 call:单发 vs --calls 序列

parseCall(OpenComputerUseCLI.swift:304)是最有内容的解析器。它区分两种形态:

单发 —— call <tool> [--args '<json>' | --args-file <path>],解析成 .single(:383)。 序列 —— call --calls '<json-array>' [--sleep <sec>]--calls-file,解析成 .sequence(:361)。

解析器做了互斥校验:--calls--calls-file 不能同时给(:350);序列形态下不能再给 tool 名/--args(:354);--sleep 只在序列下有意义(:372)。

执行在 runOpenComputerUseCall(ComputerUseToolDispatcher.swift:216)。它为整个调用新建一个 dispatcher(:221),然后:

  • .single(:224):读参数(readOpenComputerUseToolArguments)→ callToolAsResult → 输出 {content,isError}
  • .sequence(:235):读出 [OpenComputerUseCallSpec],在同一个 dispatcher 上顺序执行每一步,每步结果记进 outputs;一旦某步 isError 就 break(:247);步与步之间按 --sleep(默认 1s,OpenComputerUseCLI.swift:20)休眠(:252)。

6.3 为什么序列要"同进程"

帮助文本点破了序列的价值(OpenComputerUseCLI.swift:176):

"The JSON array form keeps all calls in one process so follow-up actions can reuse the app state and element indices captured by get_app_state."

也就是:一次 get_app_state 抓到的元素索引是有生命周期的状态。若你用两条独立的 call 命令(两个进程),第二条拿不到第一条建立的索引;而 --calls 把整串放进一个进程一个 dispatcher,后续 press_key/click 就能复用前面 get_app_state 建立的索引。这正是 MCP 侧"每回合先 get_app_state、再用 index 操作"在 CLI 上的等价物。

序列项的 schema 很宽容(readOpenComputerUseCallSequence,:277):每项是对象,工具名接受 toolname(:298),参数接受 argsarguments(:305)。

6.4 turn-ended:跨进程重置光标

CLI 的 turn-ended(.turnEnded)与 MCP 的 notifications/turn-ended同一目的、不同通道。区别在于:MCP 服务器和真正持有光标叠层的 app 可能是不同进程,不能直接调函数,得靠分布式通知跨进程喊话。

链路是:OpenComputerUseMain 收到 .turnEnded → 调 postOpenComputerUseTurnEndedNotification()(OpenComputerUseMain.swift:73)→ 通过 DistributedNotificationCenter 广播 com.ifuryst.opencomputeruse.turn-ended(SoftwareCursorOverlay.swift:137)→ 运行中的 MCP app 侧 MCPAppRuntime 早已注册了观察者(MCPAppRuntime.swift:29),收到后在主线程 resetOpenComputerUseVisualCursor()(:35)→ 最终仍是 SoftwareCursorOverlay.reset()

所以宿主(Codex 的 legacy notify)在回合结束时执行一句 open-computer-use turn-ended,就能让后台那个持光标的进程把叠层清干净。参数解析里 --previous-notify(OpenComputerUseCLI.swift:240)是为了兼容 Codex 会把上一个 notify 命令与 after-agent JSON payload 追加进 argv 的调用习惯——解析时把这些吸收掉。


7. 进程入口:OpenComputerUseMain 的分发

OpenComputerUseMain(OpenComputerUseMain.swift:7)是可执行文件的 @mainrun()(:26)的流程:

  1. 先看是不是 agent 代理调用(MacOSAppAgentProxy.isAgentInvocation,:29)——是则走代理分支(把工作转交给一个 LaunchServices app 实例,细节属 05-cross-platform-and-packaging)。
  2. 否则 parseOpenComputerUseCLI 解析命令(:34)。
  3. 可能改走代理(shouldProxy,:36):mcp/doctor/list-apps/snapshot/call 需要 app 上下文时会被代理到 app 实例执行;turn-ended/help/version 不代理(OpenComputerUseCLI.swift:32–39)。
  4. 否则本地 switch command 执行(:40)。

switch 里几处要点:

  • .mcp(:41):建 ComputerUseServiceStdioMCPServer。若视觉光标启用(VisualCursorSupport.isEnabled),把 server 跑在 MCPAppRuntime(一个 accessory 级 NSApplication,好挂光标叠层)里(:44);否则直接 server.run()
  • .call(:63):视觉光标启用时先把激活策略设成 .accessory,执行 runOpenComputerUseCall,打印 JSON,output.hasToolErrorexit(EXIT_FAILURE)(:69)——这就是脚本侧靠退出码判断成败的地方,与 §5.3 的 isError 一脉相承。
  • .turnEnded(:72):发通知并打印 turn-ended acknowledged

顶层 main()(:8)统一捕获三类错误(OpenComputerUseCLIError/ComputerUseError/兜底),写到 stderr 并 exit(EXIT_FAILURE)


8. 巧妙之处

  • 一个 dispatcher,两套错误语义收敛到同一形状。 MCP 侧靠 handle 的 catch、CLI 侧靠 callToolAsResult,两条路都把"执行失败"变成 {isError:true} 而非抛异常/JSON-RPC error——对外契约里"工具失败"始终是一个正常结果(MCPServer.swift:98ComputerUseToolDispatcher.swift:106)。
  • element_index 归一化把"schema 是 string 但大家爱填数字"这个现实缝合掉。 还顺手排除了布尔与非整数(normalizedElementIndexArgument,ComputerUseToolDispatcher.swift:3)。
  • --calls 同进程复用索引,让 CLI 能复刻 MCP"回合内多步操作共享状态"的模型,而不用起多个进程(OpenComputerUseCLI.swift:176)。
  • turn-ended 走分布式通知,跨进程把光标重置这件事解耦(SoftwareCursorOverlay.swift:137MCPAppRuntime.swift:29)。

9. 边界与局限

  • initializeprotocolVersion硬编码"2025-03-26"(MCPServer.swift:54),不与客户端协商版本。
  • capabilities 只声明 tools.listChanged:false(MCPServer.swift:60)——工具集是静态的,不发变更通知;也不提供 resources/prompts 能力。
  • JSON-RPC 层只处理请求;真正的 batch(JSON-RPC 数组)不在这一层——CLI 的 --calls 序列是应用层自己实现的,不是协议级 batch。
  • 序列执行遇错即停(ComputerUseToolDispatcher.swift:247),没有"跳过失败继续"的模式。

10. 代码地图

主题文件符号
9 个工具定义packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ToolDefinitions.swiftToolDefinitions.all
注解模板(readOnly/default)同上readOnlyAnnotations / defaultAnnotations
工具序列化同上ToolDefinition.asDictionary
JSON-RPC 循环与路由packages/OpenComputerUseKit/Sources/OpenComputerUseKit/MCPServer.swiftStdioMCPServer.handle / run
服务器 instructions 文本同上computerUseServerInstructions
工具名分发packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseToolDispatcher.swiftComputerUseToolDispatcher.callTool
element_index 归一化同上normalizedElementIndexArgument / normalizedElementIndexNumber
throw→isError 转换同上callToolAsResult
CLI 单发/序列执行同上runOpenComputerUseCall
工具结果结构packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ToolResult.swiftToolCallResult
工具错误类型packages/OpenComputerUseKit/Sources/OpenComputerUseKit/Errors.swiftComputerUseError
CLI 参数解析packages/OpenComputerUseKit/Sources/OpenComputerUseKit/OpenComputerUseCLI.swiftparseOpenComputerUseCLI / parseCall
turn-ended 分布式通知packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorOverlay.swiftpostOpenComputerUseTurnEndedNotification
进程入口分发apps/OpenComputerUse/Sources/OpenComputerUse/OpenComputerUseMain.swiftOpenComputerUseMain.run
MCP app 运行时(挂光标观察者)apps/OpenComputerUse/Sources/OpenComputerUse/MCPAppRuntime.swiftMCPAppRuntime