MCP:双向桥接
职责
MCP(Model Context Protocol)在 OpenClaw 里是双向的:既能作为 server 把自己的工具/渠道/审批事件暴露给外部 MCP client(Claude Code、Codex、Cursor 等),也能作为 client 把外部 MCP server 的工具拉进 agent 主循环的工具集。这两个方向用的是同一份 SDK(@modelcontextprotocol/sdk),但实现路径完全不同。
作 server 时,OpenClaw 启一个 stdio MCP server,把内置工具、插件工具、渠道操作(发消息、列会话、读历史、等审批)包装成 MCP 工具暴露出去;外部 client 调这些工具时,server 内部转发到 Gateway RPC 或直接调本地工具执行体。作 client 时,OpenClaw 把 mcpServers 配置里的每个 server 通过 stdio/SSE/StreamableHTTP 连接起来,把对方暴露的工具汇入 agent session 的工具 Map——这些工具和内置工具、插件工具平起平坐。
MCP 子系统的核心职责因此是:协议适配(JSON-RPC <-> MCP schema)、生命周期(连接、断线、重连、空闲清理)、工具桥接(两个方向的工具元数据/调用翻译)、事件桥接(Gateway 事件 -> MCP notification;MCP 工具调用 -> Gateway RPC)。
设计动机
为什么双向?因为 OpenClaw 的定位既是一个 agent harness(自己跑 agent),又是一个工具供应方(让别的 agent harness 调用它的能力)。Claude Code 这种外部 agent 想用 OpenClaw 的渠道能力(发 Slack、读 iMessage 历史),走 MCP 比让 Claude Code 自己实现一套适配便宜得多。反过来,OpenClaw 自己的 agent 也想用别人暴露的 MCP 工具(比如某个数据库 MCP server),作 client 让这些工具直接出现在 agent 的工具集里。
OpenClawChannelBridge(channel-bridge.ts OpenClawChannelBridge:L68-L104) 是作 server 的核心。它维护一个到 Gateway 的 WebSocket 连接,把 Gateway 事件(session.message、exec.approval.requested、plugin.approval.requested)转成 MCP notification 推给外部 client;同时把外部 client 通过 MCP tool 调用发起的请求(send 消息、读历史、resolve approval)转发成 Gateway RPC。注释里写得很清楚(src/mcp/channel-bridge.ts:L28-L33):
/**
* Runtime bridge between MCP tools and the OpenClaw Gateway channel APIs.
*
* The bridge owns readiness, event cursoring, pending approval state, and the
* narrow request methods that channel MCP tools expose to external clients.
*/"owns readiness, event cursoring, pending approval state"——这三件事是 bridge 的全部职责。readyPromise 保证外部 client 调工具前 Gateway 已经 subscribe 完毕;cursor 队列(QUEUE_LIMIT=1000)保证事件不丢且可回放;pending approval 表保证审批请求不会被外部 client 漏掉。
作 client 的核心是 resolveMcpTransport(mcp-transport.ts resolveMcpTransport:L89-L170) 和 createSessionMcpRuntime(agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555)。前者把配置里的 mcpServers 项解析成 SDK Transport 实例,后者为每个 server 起一个 Client,connect 后 listTools 拉工具元数据汇入 session 工具集。
StreamFunction 契约里有"错误进 stream 不抛"的原则,MCP 这边类似——createLazyStream 的 catch 把模块加载失败编码成 stream 事件。MCP client 这边则是把连接失败、listTools 失败转成 diagnostics,不抛错阻断 session(src/agents/agent-bundle-mcp-runtime.ts:L547-L630)。
关键文件
channel-bridge.ts OpenClawChannelBridge:L68-L104— 作 server 的 bridge,维护 Gateway 连接 + 事件队列 + pending approval。channel-bridge.ts start:L111-L185— 启动 Gateway 连接,resolve readyPromise 后才算就绪。channel-bridge.ts handleGatewayEvent:L524-L569— Gateway 事件路由到队列 + notification。channel-bridge.ts handleSessionMessageEvent:L571-L633— 用户消息转notifications/claude/channel给外部 client。channel-server.ts createOpenClawChannelMcpServer:L37-L60— 组装 McpServer + bridge + notification handler。tools-stdio-server.ts:L10-L49— stdio server factory + 生命周期 shutdown。plugin-tools-serve.ts createPluginToolsMcpServer:L57-L81— 把插件工具暴露成 MCP server。plugin-tools-handlers.ts:L24-L73—listTools/callTool处理器,套 beforeToolCall hook。mcp-transport.ts resolveMcpTransport:L89-L170— 作 client 时解析 stdio/SSE/StreamableHTTP transport。mcp-transport.ts attachStderrLogging:L34-L60— stdio server stderr 转日志,不让 stderr 噪声逃到 client。agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555— 作 client 时为每个 server 起Client+ connect。agent-bundle-mcp-runtime.ts 连接生命周期:L575-L625— 连接、超时、listTools、能力汇总。agent-bundle-mcp-runtime.ts 工具调用转发:L760-L831— session 调 MCP 工具时按 serverName 路由到对应 client。
数据流
作 server 时(channel-server.ts 组装:L37-L60):
export async function createOpenClawChannelMcpServer(opts: OpenClawMcpServeOptions = {}): Promise<{
server: McpServer;
bridge: OpenClawChannelBridge;
start: () => Promise<void>;
close: () => Promise<void>;
}> {
const cfg = await resolveMcpConfig(opts.config);
const claudeChannelMode = opts.claudeChannelMode ?? "auto";
const capabilities = getChannelMcpCapabilities(claudeChannelMode);
const server = new McpServer(
{ name: "openclaw", version: VERSION },
capabilities ? { capabilities } : undefined,
);
const bridge = new OpenClawChannelBridge(cfg, {
gatewayUrl: opts.gatewayUrl,
gatewayToken: opts.gatewayToken,
gatewayPassword: opts.gatewayPassword,
claudeChannelMode,
verbose: opts.verbose ?? false,
});
bridge.setServer(server);
server.server.setNotificationHandler(ClaudePermissionRequestSchema, async ({ params }) => {
await bridge.handleClaudePermissionRequest({ /* ... */ });
});bridge 自己持有 Gateway WebSocket 连接,事件来时进 handleGatewayEvent(src/mcp/channel-bridge.ts:L524-L569):
private async handleGatewayEvent(event: EventFrame): Promise<void> {
switch (event.event) {
case "session.message":
await this.handleSessionMessageEvent(event.payload as SessionMessagePayload);
return;
case "exec.approval.requested": {
const raw = (event.payload ?? {}) as Record<string, unknown>;
this.trackApproval("exec", raw);
this.enqueue({
cursor: this.nextCursor(),
type: "exec_approval_requested",
raw,
});
return;
}
case "exec.approval.resolved": { /* ... */ }
case "plugin.approval.requested": { /* ... */ }
case "plugin.approval.resolved": { /* ... */ }
}
}trackApproval 把审批请求存进 pendingApprovals 表(带 TTL),外部 client 可以通过 listPendingApprovals / respondToApproval 工具查询和响应。enqueue 把事件推进队列(超过 QUEUE_LIMIT=1000 时丢最老的),并唤醒所有 waitForEvent 的 waiter。handleSessionMessageEvent 还会匹配 yes/no <code> 格式的用户回复(src/mcp/channel-bridge.ts:L586-L602),转成 notifications/claude/channel/permission 推给外部 client——这是把 Claude Code 的 permission 机制桥接到聊天渠道回复的巧妙设计。
作 client 时(bundle-mcp 连接:L562-L625):
for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) {
failIfDisposed();
const resolved = resolveMcpTransport(serverName, rawServer);
if (!resolved) {
continue;
}
const safeServerName = sanitizeServerName(serverName, usedServerNames);
// ...
let session = sessions.get(serverName);
const reusedSession = Boolean(session);
let connected = Boolean(session);
if (!session) {
const client = new Client(
{ name: "openclaw-bundle-mcp", version: "0.0.0" },
{
jsonSchemaValidator: createBundleMcpJsonSchemaValidator(),
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: (error) => {
if (error) {
logWarn(`bundle-mcp: failed to refresh changed tool list for server "${serverName}": ${redactErrorUrls(error)}`);
}
catalogInvalidationGeneration += 1;
catalog = null;
catalogInFlight = undefined;
},
},
},
},
);
session = {
serverName,
client,
transport: resolved.transport,
transportType: resolved.transportType,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
detachStderr: resolved.detachStderr,
};
sessions.set(serverName, session);
}
try {
failIfDisposed();
if (!connected) {
await connectWithTimeout(
session.client,
session.transport,
resolved.connectionTimeoutMs,
);
connected = true;
}
failIfDisposed();
const capabilities = summarizeServerCapabilities(
session.client.getServerCapabilities(),
);
const listedTools = await listAllToolsBestEffort({
client: session.client,
timeoutMs: getCatalogListTimeoutMs(rawServer, resolved.requestTimeoutMs),
// ...
});注意几个设计:server name sanitize(sanitizeServerName)保证不同 server 的工具名前缀不冲突;listChanged 监听让 server 推工具列表变更时 OpenClaw 失效本地 catalog 重新拉;connectWithTimeout 防止慢 server 卡死启动;listAllToolsBestEffort 在 server 不支持 listChanged 时降级。session 级缓存(sessions.get(serverName))让重复加载不重新 connect。
作 server 暴露插件工具的入口在 plugin-tools-serve.ts:L57-L81:
export function createPluginToolsMcpServer(
params: {
config?: OpenClawConfig;
tools?: AnyAgentTool[];
} = {},
): Server {
const cfg = params.config ?? getRuntimeConfig();
const tools = params.tools ?? resolveTools(cfg);
return createToolsMcpServer({ name: "openclaw-plugin-tools", tools });
}createToolsMcpServer(src/mcp/tools-stdio-server.ts:L10-L23) 注册 ListToolsRequestSchema 和 CallToolRequestSchema 两个 handler,前者返回工具元数据,后者走 createPluginToolsMcpHandlers(src/mcp/plugin-tools-handlers.ts:L24-L73)。
callTool 处理器有个关键设计——每个工具都被 wrapToolWithBeforeToolCallHook 套上 beforeToolCall hook(src/mcp/plugin-tools-handlers.ts:L25-L32):
const wrappedTools = tools.map((tool) => {
if (isToolWrappedWithBeforeToolCallHook(tool)) {
return rewrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
}
// The ACPX MCP bridge should enforce the same pre-execution hook boundary
// as the agent and HTTP tool execution paths.
return wrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
});注释明确:"ACPX MCP bridge should enforce the same pre-execution hook boundary as the agent and HTTP tool execution paths"——外部 client 调工具时必须走和 agent 主循环一样的 beforeToolCall 边界,不能因为"是从 MCP 进来的"就绕过审批/hook。这是安全 seam。
边界与失败
- 就绪门:
OpenClawChannelBridge.start()必须等 Gateway WebSocket 完成sessions.subscribe后才resolveReadyOnce(src/mcp/channel-bridge.ts:L410-L418)。外部 client 调任何工具前都先waitUntilReady,否则会拿不到事件。 - 重试初始连接:
shouldRetryInitialMcpGatewayConnect(src/mcp/channel-bridge.ts:L650-L663) 判断错误是否可重试(gateway request timeout for connect/gateway connect challenge timeout),可重试就不rejectReadyOnce——这让 Gateway 重启时 MCP server 能撑过短暂不可达。 - 队列限长:
QUEUE_LIMIT = 1_000(src/mcp/channel-bridge.ts:L51-L58)。事件队列超长时丢最老的——保证长 MCP session 内存不爆。pending approval 还有 TTL(60min / 30min),sweepPendingExpired定时清理,且unref()掉 interval 以免 MCP stdio 进程在 client 退出后挂死(src/mcp/channel-bridge.ts:L482-L491)。 - notification 失败不抛:
sendNotificationcatch 住所有错误,只往 stderr 写一行(src/mcp/channel-bridge.ts:L389-L408)。--verbose时输出详细错误,否则只一行记录——避免 notification 失败连锁影响主流程。 - stdio stderr 隔离:
attachStderrLogging(src/agents/mcp-transport.ts:L34-L60) 把 stdio MCP server 的 stderr 转成logDebug。MCP stdio 协议要求 stdout 必须是协议专用,任何日志进 stderr;OpenClaw 进一步把 stderr 内容捕获进自己的日志系统,不让噪声逃到 client。 - server name sanitize:
sanitizeServerName(serverName, usedServerNames)(src/agents/agent-bundle-mcp-runtime.ts:L568-L573) 给同名 server 加后缀,保证工具名前缀不冲突。同时 warn 提示用户改配置。 - listChanged 失效 catalog:
tools.listChanged事件触发时,把catalog设 null、catalogInFlight设 undefined、catalogInvalidationGeneration自增(src/agents/agent-bundle-mcp-runtime.ts:L590-L600)。下一次工具调用会重新拉工具列表。 - 失败暂停 server:某个 server 反复 tool call 失败时会被"暂停",记录 retryAfterMs(
src/agents/agent-bundle-mcp-runtime.ts:L519-L520)。这防止一个挂掉的 MCP server 拖慢整个 agent 主循环。 - beforeToolCall hook 边界:作 server 暴露工具时,所有工具都被
wrapToolWithBeforeToolCallHook套上 hook(src/mcp/plugin-tools-handlers.ts:L25-L32)。这是安全 seam:外部 client 调工具必须走和 agent 主循环一样的审批/hook 边界。 - dispose 后所有调用失败:
failIfDisposed()在每个关键点检查(src/agents/agent-bundle-mcp-runtime.ts:L469-L470)。session 关闭后所有 MCP 工具调用直接抛错,不让悬挂的 client 调用造成资源泄漏。 - MCP 不是工具系统的一部分:
toolRegistry是 工具系统 的 Map,MCP 工具被桥接进来后作为这张 Map 的条目存在——它们和内置工具、插件工具平起平坐。MCP 子系统负责"连接+翻译",不负责"调用"——调用还是走 toolRegistry 的 executor。MCP server 暴露的工具也走 插件 注册的同一套 beforeToolCall hook。MCP server 的 stdio 启动和 agent 主循环的交互在 嵌入式 runner 里说明。
小结
MCP 在 OpenClaw 是双向桥接:作 server 把渠道/审批/插件工具暴露给外部 client,作 client 把外部 MCP server 的工具拉进 session 工具 Map。OpenClawChannelBridge 持有 Gateway 连接、事件队列、pending approval 表,把 Gateway 事件翻译成 MCP notification;createSessionMcpRuntime 为每个配置项起一个 Client,connect 后拉工具列表。两边都遵守"错误进 stream/队列不抛"的原则,连接失败、listTools 失败都转成 diagnostics 而非硬错。作 server 暴露的工具必须走 wrapToolWithBeforeToolCallHook——这是和 agent 主循环共用的安全 seam,外部 client 不能绕过审批/hook。MCP 工具最终汇入 工具系统 的 Map,和内置工具、插件 工具平起平坐;session 启动加载 mcpServers 的流程在 嵌入式 runner 里说明。