- notes/mcp-overview.md: MCP 协议概览,跟 tool use 的分层差异 - notes/mcp-client-internals.md: MCP client 内部实现(发现/连接/catalog) - notes/plugin-tools.md: plugin tool 与 native/MCP 的对比 - notes/lsp-tools.md: LSP 协议科普 + OpenClaw LSP tool 包装 - notes/rag-overview.md: RAG 与向量化(向量只用来检索,prompt 用原文) - notes/blog/mcp-101.md: 面向开发者的 MCP 科普,LSP 类比引入 - README 索引更新
12 KiB
OpenClaw 作为 MCP client:内部实现
基于 OpenClaw 2026.6.2 版本源码。
本文专讲 OpenClaw 作为 MCP client 那一侧的实现:server 怎么被发现、 何时连接、tool catalog 怎么暴露给模型、怎么缓存与失效、有哪些值得注意的 安全/兼容细节。
MCP 是什么、和 tool use 的关系、openclaw mcp serve 那一侧的实现见
mcp-overview.md。
整体生命周期
[发现] 读 config + plugin manifest → 一份 server map
(进程启动 / session 创建时纯 JSON 读,不 spawn)
↓
[连接] attempt 开始触发 getCatalog
→ resolveMcpTransport
→ connectWithTimeout (stdio spawn / http 握手)
→ client.listTools()
↓
[暴露] buildBundleMcpToolsFromCatalog
→ MCP tool 包装成 AnyAgentTool(name 加 server 前缀)
→ 跟 native / plugin / LSP tool 合到同一个数组
→ 发给 LLM
↓
[调用] 模型返回 tool_use → 找到 AnyAgentTool
→ execute → client.callTool()
→ 走早就建立好的 transport
↓
[缓存与失效] 同 session 的后续 attempt 复用 catalog;失效条件:
idle TTL / listChanged / 连续失败冷却
MCP server 的发现
简短答案:没有"扫描发现",全靠声明式 config。OpenClaw 从两个来源 合并出 MCP server 列表。
两个来源
1. 用户 config:openclaw.json 的 mcp.servers
类型定义在 src/config/types.mcp.ts:
export type McpConfig = {
servers?: Record<string, McpServerConfig>;
sessionIdleTtlMs?: number;
};
用户通过 CLI(openclaw mcp add/set/configure/...)或直接编辑文件,
往 ~/.openclaw/openclaw.json 里写入条目。CLI 实现在
src/cli/mcp-cli.ts,读写在 src/config/mcp-config.ts。
2. 已启用的 bundled plugin 自带的 MCP 定义
每个 plugin 在自己的 manifest 里可以声明附带的 MCP server,加载逻辑
在 src/plugins/bundle-mcp.ts:
loadEnabledBundleMcpConfig遍历manifestRegistry里所有 enabled 的 plugin- 对每个 plugin 调
loadBundleMcpConfig:- 读 plugin 的 manifest(
claude/codex/cursor三种格式之一) - 解析其中
mcpServers字段声明的相对路径列表 - 如果 plugin 根目录存在
.mcp.json也自动加入 - 把这些文件里的 server 定义和 manifest 里的 inline
mcpServersmerge 起来
- 读 plugin 的 manifest(
- 路径里的
${CLAUDE_PLUGIN_ROOT}占位符会被替换成 plugin 根目录
合并优先级
两个来源在 src/agents/bundle-mcp-config.ts 的 loadMergedBundleMcpConfig
里合并:
mcpServers: {
...enabledBundleMcp, // plugin 自带的(底)
...enabledConfiguredMcp, // 用户 config 的(覆盖在上)
}
规则:
- 同名 server,用户 config 覆盖 plugin 默认(注释里明确说 "OpenClaw config 是 owner-managed layer")
- 用户 config 里
enabled: false不仅排除自己,还能屏蔽掉 同名的 bundled server(disabledConfiguredNames集合) - 一次性的 embedded agent run 通过
loadEmbeddedAgentMcpConfig(src/agents/embedded-agent-mcp.ts)拿到最终 merged 结果
发现链路
启动 / 每次 session
│
├─ getRuntimeConfig() ← 读 ~/.openclaw/openclaw.json
│ └─ cfg.mcp.servers ← 用户层
│
├─ PluginManifestRegistry.plugins ← 已发现的 plugin manifest
│ └─ 每个 plugin 的 .mcp.json /
│ manifest mcpServers ← plugin 层
│
└─ loadMergedBundleMcpConfig({ cfg, manifestRegistry, workspaceDir })
↓
{ mcpServers: { name → config } } ← 最终 server 列表(还没 spawn)
发现性质
- 完全声明式:OpenClaw 不去网络扫描、不去 ServiceDiscovery、 不去 mDNS——所有 server 必须显式写在某个 config 文件里
- plugin 是隐式来源:用户装个 plugin,可能就"被"得到几个 MCP server,这是 plugin 作者预置的
- Config 是 process-stable:根
AGENTS.md的架构约定—— MCP server 列表在进程启动后视为稳定,改了 config 要openclaw mcp reload或重启 agent - server 列表的"发现"和 tool 的"发现"是两层:
- server 列表:静态(读 config + plugin manifest)
- 每个 server 的 tool:动态(运行时
tools/list,还支持listChanged通知)
所以严格说,OpenClaw 作为 MCP client 对 server 是静态发现, 对 tool 是动态发现。
何时连接
关键澄清:"懒"是相对"OpenClaw 启动"懒,不是相对"模型调用"懒。 每次 agent attempt(模型一轮请求)开始时,MCP server 会先全部 spawn + listTools,然后 tool catalog 才被塞进发给模型的 tool 列表里。
三种粒度的"启动"
| 粒度 | 何时发生 | 状态 |
|---|---|---|
| OpenClaw 进程启动 | openclaw 命令开始跑 |
不连接任何 MCP |
| Session 创建 | 用户第一次说话 | 不连接(只准备 runtime 壳子) |
| Attempt 开始(每轮模型请求) | 准备给模型发请求 | 必须连接 + listTools(否则模型不知道有哪些 tool) |
| Tool 实际调用 | 模型返回 tool_use | 走已建立的连接 |
"懒到模型调用时再连接"是不行的——模型必须在请求里就看到全部 tool 才能决定调哪个。OpenClaw 的折衷点在 "attempt 开始" 这一步:对每个会话, 只在它真有 attempt 时才连(避免无对话的会话开销),但一旦要 attempt, 必须先把 catalog 凑齐。
Attempt 引爆点
src/agents/embedded-agent-runner/run/attempt.ts:1502 这段是关键——
模型还没被调用前,这段已经把 MCP 全部 spawn 完了:
const bundleMcpSessionRuntime = bundleMcpEnabled
? await getOrCreateSessionMcpRuntime({...}) // 拿 runtime(可能复用)
: undefined;
bundleMcpRuntime = bundleMcpSessionRuntime
? await materializeBundleMcpToolsForRun({ // ← 这里触发 getCatalog
runtime: bundleMcpSessionRuntime,
reservedToolNames: [...],
})
: undefined;
紧接着 attempt.ts:1535 把 MCP tool 合到 allowedBundledTools,后面跟
native tool 一起发给模型。
完整时间线
进程启动 → 读 config,记下"理论上有哪些 server",不 spawn
session 创建 → runtime 壳子,sessions=空,catalog=null
attempt 开始 → getCatalog():
├─ for 每个 server:
│ - resolveMcpTransport
│ - connectWithTimeout (stdio spawn / http 握手)
│ - client.listTools()
└─ 拼成 catalog
buildBundleMcpToolsFromCatalog():
把每个 MCP tool 包装成 AnyAgentTool
(name = "serverName__toolName")
组装 LLM 请求 → tools = [...native, ...plugin, ...mcp]
LLM 收到请求 → 看到 N 个 function 定义,只看 name/description/schema
LLM 返回 → tool_use: "filesystem__read_file"
执行 → 在 tool 列表里找到对应 AnyAgentTool,
调它的 execute → runtime.callTool() → 已建立的 transport
模型视角:一个扁平的 tool 列表
模型不知道"MCP"这个概念。它只看到一个 tool 数组,每个 tool 形如:
{
"name": "filesystem__read_file",
"description": "Read contents of a file. Returns text content...",
"input_schema": { "type": "object", "properties": { "path": {...} } }
}
跟 Read、Bash 这种 native tool 在请求里完全是同一形状。区别仅在:
- 来源不同:native 是代码里写的,MCP 是从
client.listTools()拉来的 - 名字有前缀:
buildSafeToolName给 MCP tool 加serverName__前缀 避免重名,native tool 没这个前缀 - execute 走向不同:native 在本进程跑,MCP 走
client.callTool()到子进程/HTTP
attempt.ts:1535-1549 那段把所有来源 concat 起来:
const allowedBundleMcpTools = applyEmbeddedAttemptToolsAllow(
bundleMcpRuntime?.tools ?? [], ...
);
const allowedBundleLspTools = applyEmbeddedAttemptToolsAllow(
bundleLspRuntime?.tools ?? [], ...
);
const allowedBundledTools = [...allowedBundleMcpTools, ...allowedBundleLspTools];
native tool、plugin tool、MCP tool、LSP tool 全部以 AnyAgentTool[]
形式 concat,经过 provider adapter(Anthropic / OpenAI / Google)翻译
成各自的 tool schema,塞进同一个请求的 tools 字段。
模型挑哪个工具是纯粹基于 description 和 schema 跟用户意图的语义
匹配——跟它怎么挑 Read 还是 Bash 同一个机制。
扁平化的几个微妙后果
-
模型可能"搞错来源"——装了一个
memory__write的 MCP 工具, 模型在该用 nativeWrite写文件时可能调了memory__write,只因 后者 description 更像当下意图。这就是为啥 MCP server 的description写得好不好直接影响 agent 效果。 -
名字前缀是唯一的"出身标签"——
buildSafeToolName加serverName__前缀(agent-bundle-mcp-materialize.ts),既防重名,也无意中给 模型一点"分组信号"。但模型不会被告知__前面是 server 名,它只是 把整个名字当字符串看。 -
工具排序影响行为——
agent-bundle-mcp-materialize.ts显式tools.sort(...)是为了让同一份 catalog 在不同 turn 里给模型呈现的 顺序一致(对 prompt cache 命中重要,见根AGENTS.md里 "deterministic ordering" 那条)。不排序时顺序漂移会让 prompt 字节级别变化,KV cache 失效。 -
tool filter 是预算工具——
toolFilter.include/exclude在 catalog 阶段就过滤掉,模型完全感知不到"被屏蔽"的工具存在。这是抑制模型 乱调 MCP 工具的主要手段。
一句话:对 LLM 来说,世界是扁平的一组 function;MCP 是 OpenClaw 这 一层的实现细节,跨过 provider boundary 之后就消失了。
Catalog 缓存与失效
agent-bundle-mcp-runtime.ts 的 getCatalog 有缓存——同一个 session 的
后续 attempt 不再重新 listTools,直接复用 catalog。
失效条件:
- session 空闲超过
mcp.sessionIdleTtlMs(默认 10 分钟)被回收, 下次 attempt 时 catalog 重新拼 - MCP server 发了
listChanged通知,onChangedcallback 里catalog = null强制下次刷新 - 某个 server 连续 3 次失败,进入 60s 冷却期(后面 "Failure 退避" 段)
这就是"server 列表静态发现 + tool 列表动态发现 + tool catalog 缓存" 三层的实际落地。
几个值得注意的实现细节
JSON Schema 兼容(agent-bundle-mcp-runtime.ts createBundleMcpJsonSchemaValidator):
MCP 用 JSON Schema draft 2020-12,而 TypeBox/Ajv 对一些 keyword 支持
不一致,OpenClaw 用 stripJsonSchemaFormats + normalizeJsonSchemaForTypeBox
做 schema 归一化后再交给 TypeBox 编译。
Content block 收敛(agent-bundle-mcp-materialize.ts mcpContentBlockToToolResult):
MCP 的 CallToolResult 可以返回 text/image/audio/resource_link/resource 等
多种 block,但 OpenClaw 的 AgentToolResult 只支持 text/image。多余类型
被降级成 text,避免 provider(Anthropic)拒掉无效 image block 后整个
session 历史被污染(注释里点了 issue #90710)。
Stdio env 安全过滤(docs/cli/mcp.md "Stdio env safety filter"):
NODE_OPTIONS、PYTHONSTARTUP、LD_PRELOAD 这类能在 RPC 之前改变
解释器行为的 env 被禁止写入 stdio server 的 env 字段。
Failure 退避(agent-bundle-mcp-runtime.ts 常量 BUNDLE_MCP_FAILURE_THRESHOLD):
同一 server 连续 3 次失败会触发 60s 冷却,防止一个坏 server 阻塞整个
agent turn。
Tool filter:每个 server 可配 toolFilter.include/exclude(支持 * glob),
catalog 阶段就把不想暴露给模型的工具过滤掉。
相关
- mcp-overview.md — MCP 协议本身、OpenClaw 的两个 方向、MCP 与 tool use 的对比
docs/cli/mcp.md— 官方面向用户的 CLI 文档