跳到主要内容

数据截至 (上游 commit 3309bf4e416f)

03 — 工具系统与 MCP 双向桥

这章讲什么: 先看「一个工具最少要长什么样」,再看代表性工具各自的巧思, 最后看 OpenManus 怎么同时当 MCP 的客户端(用别人的工具)和服务端 (把自己的工具借给别人)。


1. 工具的最小契约

1.1 三个字段 + 一个方法

BaseTool 同时继承 ABCBaseModel(app/tool/base.py:78-137):

成员类型作用
namestr模型调用时用的名字
descriptionstr写给模型看的说明书,决定它会不会用对
parametersdictJSON Schema,描述入参
execute(**kwargs)抽象协程真正干活的地方

加上一个把自己翻译成 OpenAI 格式的方法(app/tool/base.py:124-137):

def to_param(self) -> Dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}

要点: descriptionparameters 是纯手写的字符串/字典,不是从 Python 类型 自动推导的。看 app/tool/str_replace_editor.py:37-100 就知道——那一大段说明文字 就是这个工具的全部「用户手册」,模型只能靠它判断怎么用。

1.2 ToolResult:可加、可判真、可替换

ToolResult 有四个字段:outputerrorbase64_imagesystem (app/tool/base.py:38-75),外加三个小设计:

方法行为为什么有用
__bool__任一字段非空即真if result: 直接判断有没有产出
__add__逐字段拼接;base64_image 冲突时抛错合并多段输出
__str__error 就显示错误,否则显示 output直接 str(result) 喂给模型

子类 CLIResultToolFailure 只是语义标记,没有额外行为 (app/tool/base.py:176-181)。

1.3 ToolCollection:一张名字表

ToolCollection(app/tool/tool_collection.py:9-71)本质是 tools 元组 + tool_map 字典,提供三件事:

  • to_params() —— 把全表转成 schema 列表,交给模型。
  • execute(name=..., tool_input=...) —— 按名字找工具并 await tool(**tool_input); 找不到返回 ToolFailure,ToolError 也被转成 ToolFailure(:25-35)。
  • add_tool() —— 重名会被跳过并打警告(:56-58),不是覆盖。

最后这条在 MCP 场景很重要:两台服务器提供同名工具时,先连上的赢。


2. 代表性工具里的巧思

2.1 str_replace_editor:唯一匹配 + 环境无关

这是最值得读的一个工具(app/tool/str_replace_editor.py),五个命令: view / create / str_replace / insert / undo_edit

巧思一:替换必须唯一。 str_replace 先数出现次数(:297-314):

occurrences = file_content.count(old_str)
if occurrences == 0:
raise ToolError(f"No replacement was performed, old_str ... did not appear verbatim")
elif occurrences > 1:
raise ToolError(f"... Multiple occurrences of old_str ... in lines {lines}. Please ensure it is unique")

出现 0 次或多次都拒绝,并且多次时把行号列出来告诉模型该补多少上下文。 这是一种「宁可失败也不猜」的策略——和 Aider 那种多级模糊匹配是相反的取舍 (对比见 07 章)。

巧思二:改完回一段带行号的快照。 替换后截取改动点前后各 4 行 (SNIPPET_LINES = 4,:28)返回给模型(:325-338),让它自己核对改对没有。

巧思三:环境无关。 所有文件读写都走一个 operator,而 operator 是运行时选的 (:106-112):

def _get_operator(self) -> FileOperator:
return (
self._sandbox_operator
if config.sandbox.use_sandbox
else self._local_operator
)

同一份工具代码,配置一改就从「改本机文件」变成「改容器里的文件」 (详见 06 章)。

巧思四:撤销靠内存栈。 _file_history 是个 defaultdict(list)(:101), 每次改动前把旧内容 push 进去,undo_edit 就 pop 回写(:394-406)。 进程一退,历史全丢。

2.2 bash:一个不会退出的 shell 会话

难点是:每条命令都新起一个 bash 的话,cd 和环境变量就丢了。

_BashSession(app/tool/bash.py:16-113)的做法是开一个常驻的 bash 子进程, 用一个哨兵字符串判断「这条命令跑完了」:

_sentinel: str = "<<exit>>"
...
self._process.stdin.write(command.encode() + f"; echo '{self._sentinel}'\n".encode())

然后轮询直到输出里出现哨兵(app/tool/bash.py:82-93)。

代价是一处 hack: 它直接读了 asyncio 流的私有缓冲区 self._process.stdout._buffer(app/tool/bash.py:87-89),并在读完后手动 clear()(:110-111)。注释解释了原因——正常 read() 会一直等 EOF,而这个 shell 永远不会 EOF。有效,但依赖 CPython 私有实现。

2.3 python_execute:名字里的「安全」要打折看

工具 docstring 写的是「with timeout and safety restrictions」 (app/tool/python_execute.py:10),实际上:

  • 超时是真的:另起一个 multiprocessing.Process,join(timeout) 后还活着就 terminate()(:61-73)。

  • 隔离是假的:所谓 safe_globals 其实是完整的内置命名空间副本(:57-60):

    safe_globals = {"__builtins__": __builtins__.__dict__.copy()}

    open__import__os 全都可用。真正的隔离要靠 06 章 的 Docker 沙箱,而 PythonExecute 不走沙箱

还有一个使用上的坑:它只捕获 stdout,函数返回值看不见——所以 description 里 特意写了「Only print outputs are visible」(:13)。

2.4 web_search:四引擎降级链

WebSearch 内置 Google / Baidu / DuckDuckGo / Bing 四个引擎 (app/tool/web_search.py:193-198),排序规则在 _get_engine_order(:360-385):

配置的首选引擎
│ 失败

配置的 fallback 列表(默认 DuckDuckGo → Baidu → Bing)
│ 全失败

剩下没试过的引擎
│ 还是全失败

等 retry_delay 秒(默认 60),整轮重来,最多 max_retries 次(默认 3)

单个引擎调用本身还包了一层 tenacity 重试(:387-389)。 三层容错是因为免费搜索接口的限流实在太常见。

注意: WebSearch 虽然在 app/tool/__init__.py:9 里导出, 但没有被任何智能体装进工具表。同样情况的还有 Crawl4aiToolComputerUseTool——搜索与浏览现在统一走 MCP 的 Browser Use。

2.5 ask_human:一条同步的 input()

最短的工具(app/tool/ask_human.py:20-21),就是一句 input()。 它是阻塞的同步调用,会卡住整个事件循环——在 CLI 场景无所谓, 搬进服务端就得换掉。


3. MCP 是什么(一句话)

MCP(Model Context Protocol,模型上下文协议) 是 Anthropic 提的一套标准: 工具提供方跑一个「服务器」,agent 当「客户端」连上去,协议规定了 「怎么列出工具、怎么调用工具、怎么回传结果」。

好处很直接:工具不用写进 agent 的代码库。别人写好一个 MCP 服务器, 你连上就能用。

OpenManus 两头都做了:

别人的 MCP 服务器 别人的 agent
(浏览器 / 数据库 / …) │
▲ │ MCP 协议
│ MCP 协议 ▼
┌──────┴───────┐ ┌────────────────┐
│ MCPClients │ OpenManus │ MCPServer │
│ 客户端 │ ───────────────── │ 服务端 │
└──────────────┘ └────────────────┘
把远程工具变成 把 bash/editor/terminate
本地 BaseTool 暴露成 MCP 工具

4. 客户端:MCPClients

4.1 关键设计:它就是一张工具表

class MCPClients(ToolCollection):

app/tool/mcp.py:49 这一行是整个集成的支点。因为 MCPClients 继承ToolCollection,远程工具和本地工具落在同一张 tool_map 里, ToolCallAgent 完全不需要知道哪个是远程的。

每个远程工具被包成 MCPClientTool(app/tool/mcp.py:14-46),它的 execute 就是一次 RPC:

result = await self.session.call_tool(self.original_name, kwargs)

返回内容里的 TextContent 拼成字符串、ImageContent 取第一张塞进 base64_image (app/tool/mcp.py:29-44)——正好对上 02 章 讲的图片回传路径。

4.2 两种传输方式

方式怎么连配置字段函数
stdio起一个子进程,走标准输入输出command + argsconnect_stdio(:87-116)
sse连一个 HTTP 端点urlconnect_sse(:66-85)

两条路径都用 AsyncExitStack 统一管理生命周期(:77-83:105-113), 断开时只要 await exit_stack.aclose() 就能把 session 和传输层一起收干净。

4.3 工具改名与消毒

远程工具默认会被加前缀(app/tool/mcp.py:133-139):

tool_name = (
f"mcp_{server_id}_{original_name}"
if tool_name_prefix
else original_name
)
tool_name = self._sanitize_tool_name(tool_name)

_sanitize_tool_name(:157-174)做四件事:非法字符换下划线 → 连续下划线合并 → 去掉首尾下划线 → 截断到 64 字符。64 是 OpenAI function 名字的长度上限。

工具对象里同时存了 name(改过的)和 original_name(原始的), 调用远程时用后者(:28)。这层名字映射是能同时挂多台服务器的前提。

4.4 服务器的「使用说明」会被注入系统消息

MCP 握手时服务器可以回一段 instructions。OpenManus 把它存下来 (app/tool/mcp.py:126-128),然后在 Manus 里写成一条 system 消息 (app/agent/manus.py:158-171):

self.memory.add_message(
Message.system_message(
f"{transport_instructions}MCP server instructions:\n{instructions}"
)
)

这等于让 MCP 服务器往 agent 的系统提示里写字。 好处是外部工具能自带用法教学; 代价是信任边界变模糊——你连的服务器可以直接影响模型行为。

4.5 默认自动挂 Browser Use

Manus 启动时会先于配置文件尝试连一台固定的服务器 (app/agent/manus.py:18-20、85-99):

_BROWSER_USE_COMMAND = "uvx"
_BROWSER_USE_ARGS = ["browser-use", "--cli-mcp"]

三个细节:

  • 可以关掉:环境变量 OPENMANUS_DISABLE_BROWSER_USE=1(:86-87)。
  • 不加前缀:tool_name_prefix=False(:94),所以工具就叫 browser_exec / browser_screenshot,和 Browser Use 官方文档一致。
  • 透传凭据:_browser_use_env()(:37-38)把 BROWSER_USE_API_KEYBU_CDP_URL 等六个环境变量挑出来传给子进程。

这一步是本仓库近期最大的架构变化:浏览器能力从「内置工具」变成了「默认 MCP 服务器」, 所以 app/tool/ 下已经没有 browser_use_tool.py 了。

4.6 MCPAgent:工具清单会变

远程工具是动态的——服务器随时可能上下线工具。MCPAgent 因此每 5 步刷新一次 (app/agent/mcp.py:34、157-164),并把变化用 system 消息告诉模型 (app/agent/mcp.py:132-143):

self.memory.add_message(
Message.system_message(f"New tools available: {', '.join(added_tools)}")
)

还有一条退出条件:工具全没了就认为服务挂了,直接置 FINISHED (app/agent/mcp.py:151-164)。


5. 服务端:MCPServer 的签名合成术

5.1 问题

FastMCP 注册工具的方式是「装饰一个普通 Python 函数」,它靠函数的 类型注解和 docstring 生成工具 schema。而 OpenManus 的工具把 schema 写在 parameters 字典里,函数签名只有 **kwargs

5.2 解法:反过来合成签名

MCPServer.register_tool(app/mcp/server.py:35-74)先定义一个通吃的 async def tool_method(**kwargs),然后手动伪造它的元信息:

tool_method.__name__ = tool_name
tool_method.__doc__ = self._build_docstring(tool_function)
tool_method.__signature__ = self._build_signature(tool_function)
  • _build_docstring(:76-96)把 description + 每个参数的 「名字(类型)(required/optional): 说明」拼成一段文档。
  • _build_signature(:98-134)把 JSON Schema 的类型名映射回 Python 类型 (string→strinteger→intarray→list…),造出一串 Parameter(kind=KEYWORD_ONLY),再包成 inspect.Signature

妙在哪: 这是一次「schema → 函数签名」的反向翻译。有了它, 任何 BaseTool 都能零改动地变成 MCP 工具,不用为每个工具写一遍适配函数。

5.3 暴露了哪三个工具

app/mcp/server.py:31-33 写死了三个:basheditor(即 StrReplaceEditor)、 terminate。传输方式只支持 stdio(:161-166choices=["stdio"], 尽管 help 文字里还写着 http)。

run_mcp.py 演示了一个闭环用法:它用 sys.executable -m app.mcp.server 起服务端子进程,再用 MCPAgent 连上去(run_mcp.py:27-32)—— OpenManus 自己当自己的工具提供方


6. 加一个工具需要几步

① 新建类继承 BaseTool
name / description / parameters 三个字段填好
实现 async def execute(**kwargs) -> ToolResult | str

② 加进某个智能体的 available_tools
app/agent/manus.py:57-64 那个 ToolCollection(...)

③ (可选) 想让别的 agent 也能用
加进 app/mcp/server.py:31-33 的 self.tools 字典

注意第 ① 步里 description 的质量决定一切——模型只看这段文字。


7. 代码地图

主题文件路径符号名
工具基类 / 结果app/tool/base.pyBaseToolToolResultCLIResultToolFailure
schema 生成app/tool/base.pyBaseTool.to_param
工具表app/tool/tool_collection.pyToolCollectionToolCollection.executeadd_tool
精确文件编辑app/tool/str_replace_editor.pyStrReplaceEditorStrReplaceEditor.str_replace
常驻 shell 会话app/tool/bash.pyBash_BashSession
Python 执行app/tool/python_execute.pyPythonExecute
多引擎搜索app/tool/web_search.pyWebSearchWebSearch._get_engine_order
搜索引擎接口app/tool/search/base.pyWebSearchEngineSearchItem
反问人类app/tool/ask_human.pyAskHuman
MCP 工具代理app/tool/mcp.pyMCPClientTool
MCP 客户端集合app/tool/mcp.pyMCPClientsconnect_stdioconnect_sse
工具名消毒app/tool/mcp.pyMCPClients._sanitize_tool_name
MCP 服务端app/mcp/server.pyMCPServerregister_tool_build_signature
MCP 智能体app/agent/mcp.pyMCPAgentMCPAgent._refresh_tools
Browser Use 自动挂载app/agent/manus.pyManus.initialize_mcp_servers_browser_use_env
MCP 服务器配置app/config.pyMCPSettings.load_server_configMCPServerConfig