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 裡說明。