Files
openclaw-notes/mcp-client-internals.md
clz 3c0cd86352 docs: add MCP, plugin/LSP tool, and RAG notes
- 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 索引更新
2026-06-16 00:59:32 +08:00

298 lines
12 KiB
Markdown

# OpenClaw 作为 MCP client:内部实现
基于 [OpenClaw](https://github.com/openclaw/openclaw) 2026.6.2 版本源码。
本文专讲 OpenClaw 作为 MCP client 那一侧的实现:server 怎么被发现、
何时连接、tool catalog 怎么暴露给模型、怎么缓存与失效、有哪些值得注意的
安全/兼容细节。
MCP 是什么、和 tool use 的关系、`openclaw mcp serve` 那一侧的实现见
[mcp-overview.md](./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`:
```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`:
1. 读 plugin 的 manifest(`claude` / `codex` / `cursor` 三种格式之一)
2. 解析其中 `mcpServers` 字段声明的相对路径列表
3. 如果 plugin 根目录存在 `.mcp.json` 也自动加入
4. 把这些文件里的 server 定义和 manifest 里的 inline `mcpServers`
merge 起来
- 路径里的 `${CLAUDE_PLUGIN_ROOT}` 占位符会被替换成 plugin 根目录
### 合并优先级
两个来源在 `src/agents/bundle-mcp-config.ts``loadMergedBundleMcpConfig`
里合并:
```ts
mcpServers: {
...enabledBundleMcp, // plugin 自带的(底)
...enabledConfiguredMcp, // 用户 config 的(覆盖在上)
}
```
规则:
1. **同名 server**,用户 config 覆盖 plugin 默认(注释里明确说
"OpenClaw config 是 owner-managed layer")
2. 用户 config 里 `enabled: false` 不仅排除自己,还能**屏蔽掉
同名的 bundled server**(`disabledConfiguredNames` 集合)
3. 一次性的 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)
```
### 发现性质
1. **完全声明式**:OpenClaw 不去网络扫描、不去 ServiceDiscovery、
不去 mDNS——所有 server 必须显式写在某个 config 文件里
2. **plugin 是隐式来源**:用户装个 plugin,可能就"被"得到几个 MCP
server,这是 plugin 作者预置的
3. **Config 是 process-stable**:根 `AGENTS.md` 的架构约定——
MCP server 列表在进程启动后视为稳定,改了 config 要
`openclaw mcp reload` 或重启 agent
4. **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 完了:
```ts
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 形如:
```json
{
"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 起来:
```ts
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` 同一个机制。
### 扁平化的几个微妙后果
1. **模型可能"搞错来源"**——装了一个 `memory__write` 的 MCP 工具,
模型在该用 native `Write` 写文件时可能调了 `memory__write`,只因
后者 description 更像当下意图。这就是为啥 MCP server 的 `description`
写得好不好直接影响 agent 效果。
2. **名字前缀是唯一的"出身标签"**——`buildSafeToolName``serverName__`
前缀(`agent-bundle-mcp-materialize.ts`),既防重名,也无意中给
模型一点"分组信号"。但模型不会被告知 `__` 前面是 server 名,它只是
把整个名字当字符串看。
3. **工具排序影响行为**——`agent-bundle-mcp-materialize.ts` 显式
`tools.sort(...)` 是为了让同一份 catalog 在不同 turn 里给模型呈现的
顺序一致(对 prompt cache 命中重要,见根 `AGENTS.md`
"deterministic ordering" 那条)。不排序时顺序漂移会让 prompt
字节级别变化,KV cache 失效。
4. **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` 通知,`onChanged` callback 里
`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-overview.md) — MCP 协议本身、OpenClaw 的两个
方向、MCP 与 tool use 的对比
- `docs/cli/mcp.md` — 官方面向用户的 CLI 文档