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 呼び出しで起こしたリクエスト(メッセージ送信、履歴読み取り、承認 resolve)を 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」——この 3 件事が 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 接続を起動,readyPromise を resolve して就緒。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 はイベントをキューにpush(QUEUE_LIMIT=1000 超過時は最も古いものを捨て),すべての waitForEvent 待機者を喚起します。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 がツールリスト変更を push したとき OpenClaw がローカル catalog を失効して再取得します。connectWithTimeout は遅い server が起動を hang させるのを防ぎます。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 の 2 つの handler を登録し,前者はツールメタデータを返し,後者は createPluginToolsMcpHandlers(src/mcp/plugin-tools-handlers.ts:L24-L73) を走らせます。
callTool handler には重要な設計があります——各ツールは 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 をバイパスできません。これはセキュリティ継ぎ目です。
境界と失敗
- 就緒門:
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が定時清理し,さらに interval をunref()して MCP stdio プロセスが client 退出後に hang しないようにします(src/mcp/channel-bridge.ts:L482-L491)。 - notification 失敗はスローしない:
sendNotificationはすべてのエラーを catch し,stderr に 1 行書くだけ(src/mcp/channel-bridge.ts:L389-L408)。--verbose時は詳細エラーを出力,さもなくば 1 行だけ——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)。これで 1 つの壊れた MCP server が agent メインループ全体を引きずり遅くするのを防ぎます。 - beforeToolCall hook 境界:server としてツールを公開するとき,すべてのツールに
wrapToolWithBeforeToolCallHookで hook を付け(src/mcp/plugin-tools-handlers.ts:L25-L32)ます。これはセキュリティ継ぎ目:外部 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 メインループの相互作用は Embedded 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 メインループと共用のセキュリティ継ぎ目で,外部 client は承認/hook をバイパスできません。MCP ツールは最終的に ツールシステム の Map に合流し,ビルトインツール、プラグイン ツールと対等になります。session 起動時に mcpServers を読み込む流れは Embedded runner で説明します。
公式資料:MCP ドキュメント · README。