MCP: Bidirektionale Brücke
Verantwortung
MCP (Model Context Protocol) ist in OpenClaw bidirektional: Es kann als Server eigene Werkzeuge/Kanäle/Approval-Ereignisse an externe MCP-Clients (Claude Code, Codex, Cursor u. a.) exponieren und als Client Werkzeuge externer MCP-Server in die Werkzeug-Map der Agent-Hauptschleife importieren. Beide Richtungen nutzen dasselbe SDK (@modelcontextprotocol/sdk), aber die Implementierungspfade sind völlig verschieden.
Als Server startet OpenClaw einen stdio-MCP-Server, der eingebaute Werkzeuge, Plugin-Werkzeuge und Kanaloperationen (Nachricht senden, Sitzungen auflisten, Historie lesen, Approvals einholen) als MCP-Werkzeuge exponiert; wenn ein externer Client diese Werkzeuge aufruft, leitet der Server intern zum Gateway-RPC weiter oder ruft direkt den lokalen Werkzeug-Executor auf. Als Client verbindet OpenClaw jeden in der mcpServers-Konfiguration angegebenen Server über stdio/SSE/StreamableHTTP und importiert die vom Gegner exponierten Werkzeuge in die Werkzeug-Map der Agent-Sitzung — diese Werkzeuge stehen gleichberechtigt neben eingebauten und Plugin-Werkzeugen.
Die Kernverantwortung des MCP-Subsystems ist daher: Protokoll-Anpassung (JSON-RPC <-> MCP schema), Lebenszyklus (Verbinden, Trennen, Reconnect, Idle-Cleanup), Werkzeug-Brückung (Übersetzung der Werkzeug-Metadaten/Aufrufe in beide Richtungen), Ereignis-Brückung (Gateway-Ereignis -> MCP-Benachrichtigung; MCP-Werkzeugaufruf -> Gateway-RPC).
Designmotivation
Warum bidirektional? Weil OpenClaw sowohl ein Agent-Harness ist (führt selbst Agenten aus) als auch ein Werkzeug-Lieferant (lässt andere Agent-Harnesses seine Fähigkeiten aufrufen). Ein externer Agent wie Claude Code, das die Kanalfähigkeiten von OpenClaw nutzen will (Slack-Nachricht senden, iMessage-Historie lesen), ist über MCP besser bedient als selbst eine Adapter-Schicht zu implementieren. Umgekehrt möchte der OpenClaw-Agent selbst MCP-Werkzeuge anderer verwenden (z. B. ein Datenbank-MCP-Server) — als Client erscheinen diese Werkzeuge direkt in der Werkzeug-Map des Agenten.
OpenClawChannelBridge (channel-bridge.ts OpenClawChannelBridge:L68-L104) ist der Kern der Server-Seite. Er pflegt eine WebSocket-Verbindung zum Gateway, übersetzt Gateway-Ereignisse (session.message, exec.approval.requested, plugin.approval.requested) in MCP-Benachrichtigungen an den externen Client; gleichzeitig leitet er Anfragen des externen Clients über MCP-Tool-Aufrufe (Nachricht senden, Historie lesen, Approval auflösen) an Gateway-RPC weiter. Der Kommentar sagt es deutlich (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" — diese drei Aufgaben sind die gesamte Verantwortung der Bridge. readyPromise stellt sicher, dass das Gateway abonniert hat, bevor ein externer Client Werkzeuge aufruft; die Cursor-Warteschlange (QUEUE_LIMIT=1000) stellt sicher, dass Ereignisse nicht verloren gehen und replaybar sind; die Pending-Approval-Tabelle stellt sicher, dass Approval-Anfragen vom externen Client nicht übersehen werden.
Auf der Client-Seite sind resolveMcpTransport (mcp-transport.ts resolveMcpTransport:L89-L170) und createSessionMcpRuntime (agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555) zentral. Ersteres resolved Einträge aus der Konfiguration mcpServers in SDK-Transport-Instanzen; Letzteres startet pro Server einen Client, ruft nach dem Connect listTools auf und importiert die Werkzeug-Metadaten in die Session-Werkzeug-Map.
Im StreamFunction-Vertrag gilt „Fehler in Stream, nicht werfen"; MCP-Seite ähnelt — der catch in createLazyStream kodiert Modulladefehler als Stream-Ereignis. Auf der Client-Seite werden Connection- und listTools-Fehler in Diagnostics gewandelt und nicht geworfen, um die Session nicht zu blockieren (src/agents/agent-bundle-mcp-runtime.ts:L547-L630).
Schlüsseldateien
channel-bridge.ts OpenClawChannelBridge:L68-L104— Bridge als Server, pflegt Gateway-Verbindung + Ereignis-Warteschlange + Pending-Approval.channel-bridge.ts start:L111-L185— Startet Gateway-Verbindung, resolved readyPromise.channel-bridge.ts handleGatewayEvent:L524-L569— Gateway-Ereignis-Routing an Warteschlange + Benachrichtigung.channel-bridge.ts handleSessionMessageEvent:L571-L633— Nutzer-Nachrichten werden zunotifications/claude/channelfür den externen Client.channel-server.ts createOpenClawChannelMcpServer:L37-L60— Setzt McpServer + Bridge + Notification-Handler zusammen.tools-stdio-server.ts:L10-L49— stdio-Server-Fabrik + Lifecycle-Shutdown.plugin-tools-serve.ts createPluginToolsMcpServer:L57-L81— Exponiert Plugin-Werkzeuge als MCP-Server.plugin-tools-handlers.ts:L24-L73—listTools/callTool-Handler mit beforeToolCall-Hook.mcp-transport.ts resolveMcpTransport:L89-L170— Löst auf der Client-Seite stdio/SSE/StreamableHTTP-Transport auf.mcp-transport.ts attachStderrLogging:L34-L60— stdio-Server-stderr wird zu Logs, verhindert stderr-Rauschen beim Client.agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555— Startet auf der Client-Seite pro Server einenClient+ Connect.agent-bundle-mcp-runtime.ts Verbindungs-Lebenszyklus:L575-L625— Verbinden, Timeout, listTools, Fähigkeiten zusammenfassen.agent-bundle-mcp-runtime.ts Werkzeugaufruf-Forwarding:L760-L831— Session-Routing nach serverName an den richtigen Client.
Datenfluss
Auf der Server-Seite (channel-server.ts Assembly: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({ /* ... */ });
});Die Bridge hält ihre eigene Gateway-WebSocket-Verbindung. Bei eingehendem Ereignis läuft 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 speichert die Approval-Anfrage in der pendingApprovals-Tabelle (mit TTL), über die der externe Client listPendingApprovals / respondToApproval abfragen und reagieren kann. enqueue schiebt das Ereignis in die Warteschlange (bei Überschreitung von QUEUE_LIMIT=1000 wird das älteste verworfen) und weckt alle waitForEvent-Waiter. handleSessionMessageEvent matcht zudem Antworten im Format yes/no <code> (src/mcp/channel-bridge.ts:L586-L602) und wandelt sie in notifications/claude/channel/permission für den externen Client um — eine elegante Brückung des Claude-Code-Permission-Mechanismus in Chat-Kanal-Antworten.
Auf der Client-Seite (bundle-mcp Verbindung: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),
// ...
});Mehrere Designentscheidungen: server name sanitize (sanitizeServerName) stellt sicher, dass Werkzeug-Präfixe verschiedener Server nicht kollidieren; listChanged-Listener lässt OpenClaw bei Werkzeuglistenänderungen durch Server den lokalen Catalog invalidieren und neu abfragen; connectWithTimeout verhindert, dass ein langsamer Server den Start blockiert; listAllToolsBestEffort degradiert, wenn ein Server listChanged nicht unterstützt. Session-Caching (sessions.get(serverName)) lässt wiederholtes Laden ohne neuen Connect.
Einstieg für das Exponieren von Plugin-Werkzeugen als Server in 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) registriert Handler für ListToolsRequestSchema und CallToolRequestSchema — ersterer gibt Werkzeug-Metadaten zurück, letzterer läuft über createPluginToolsMcpHandlers (src/mcp/plugin-tools-handlers.ts:L24-L73).
Ein Schlüsseldesign im callTool-Handler — jedes Werkzeug wird durch wrapToolWithBeforeToolCallHook mit einem beforeToolCall-Hook versehen (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" });
});Der Kommentar ist explizit: „ACPX MCP bridge should enforce the same pre-execution hook boundary as the agent and HTTP tool execution paths" — der externe Client muss bei Werkzeugaufrufen dieselbe beforeToolCall-Grenze durchlaufen wie die Agent-Hauptschleife und darf Approval/Hooks nicht umgehen, nur weil der Aufruf „über MCP hereinkommt". Das ist eine Sicherheitsnaht.
Grenzen und Fehler
- Ready-Gate:
OpenClawChannelBridge.start()muss warten, bis das Gateway-WebSocketsessions.subscribeabgeschlossen hat, bevorresolveReadyOnce(src/mcp/channel-bridge.ts:L410-L418) aufgerufen wird. Der externe Client ruft vor jedem WerkzeugaufrufwaitUntilReadyauf, sonst fehlen Ereignisse. - Retry der初始verbindung:
shouldRetryInitialMcpGatewayConnect(src/mcp/channel-bridge.ts:L650-L663) prüft, ob der Fehler wiederholbar ist (gateway request timeout for connect/gateway connect challenge timeout); ist er wiederholbar, wirdrejectReadyOncenicht aufgerufen — so kann der MCP-Server kurze Gateway-Unerreichbarkeit beim Neustart überbrücken. - Warteschlangen-Längenbegrenzung:
QUEUE_LIMIT = 1_000(src/mcp/channel-bridge.ts:L51-L58). Bei Überlänge wird das älteste verworfen — damit lange MCP-Sessions nicht den Speicher sprengen. Pending-Approvals haben zusätzlich TTL (60min / 30min);sweepPendingExpiredräumt regelmäßig auf; außerdemunref()des Intervalls, damit der stdio-Prozess nach Client-Exit nicht hängen bleibt (src/mcp/channel-bridge.ts:L482-L491) . - Notification-Fehler werden nicht geworfen:
sendNotificationfängt alle Fehler ab und schreibt nur eine Zeile nach stderr (src/mcp/channel-bridge.ts:L389-L408). Bei--verbosewird der detaillierte Fehler ausgegeben, sonst nur eine Einzeiler-Notiz — verhindert Kettenreaktionen durch Notification-Fehler. - stdio-stderr-Isolierung:
attachStderrLogging(src/agents/mcp-transport.ts:L34-L60) wandelt stderr des stdio-MCP-Servers inlogDebugum. Das MCP-stdio-Protokoll verlangt, dass stdout ausschließlich dem Protokoll gehört; jegliche Logs gehen nach stderr. OpenClaw fängt stderr-Inhalte zusätzlich in das eigene Logsystem ab und verhindert, dass Rauschen zum Client durchsickert. - server name sanitize:
sanitizeServerName(serverName, usedServerNames)(src/agents/agent-bundle-mcp-runtime.ts:L568-L573) hängt an gleichnamige Server einen Suffix, damit Werkzeug-Präfixe nicht kollidieren. Gleichzeitig warnt es den Nutzer, die Konfiguration anzupassen. - listChanged invalidiert Catalog: Beim
tools.listChanged-Ereignis wirdcatalogauf null gesetzt,catalogInFlightauf undefined undcatalogInvalidationGenerationinkrementiert (src/agents/agent-bundle-mcp-runtime.ts:L590-L600). Der nächste Werkzeugaufruf zieht die Werkzeugliste neu. - Server bei wiederholten Fehlern pausiert: Ein Server mit wiederholten Tool-Call-Fehlern wird pausiert und ein
retryAfterMsvermerkt (src/agents/agent-bundle-mcp-runtime.ts:L519-L520). Das verhindert, dass ein kaputter MCP-Server die gesamte Agent-Hauptschleife verlangsamt. - beforeToolCall-Hook-Grenze: Auf der Server-Seite werden alle Werkzeuge mit
wrapToolWithBeforeToolCallHookumwickelt (src/mcp/plugin-tools-handlers.ts:L25-L32). Das ist die Sicherheitsnaht: Externe Client-Aufrufe müssen dieselbe Approval/Hook-Grenze durchlaufen wie die Agent-Hauptschleife. - Nach Dispose schlagen alle Aufrufe fehl:
failIfDisposed()prüft an jedem Schlüsselpunkt (src/agents/agent-bundle-mcp-runtime.ts:L469-L470). Nach dem Schließen der Session schlagen alle MCP-Werkzeugaufrufe direkt fehl; verhindert, dass hängende Client-Aufrufe Ressourcenleckagen verursachen. - MCP ist nicht Teil des Werkzeugsystems:
toolRegistryist die Map des Werkzeugsystems; MCP-Werkzeuge werden nach der Brückung als Entries dieser Map abgelegt — sie stehen gleichberechtigt neben eingebauten und Plugin-Werkzeugen. Das MCP-Subsystem ist für „Verbinden + Übersetzen" zuständig, nicht für „Aufrufen" — der Aufruf läuft weiterhin über den Executor der toolRegistry. Werkzeuge, die der MCP-Server exponiert, durchlaufen ebenfalls denselben beforeToolCall-Hook wie Plugins. Wie der stdio-Start des MCP-Servers mit der Agent-Hauptschleife interagiert, wird in Embedded Runner erläutert.
Zusammenfassung
MCP ist in OpenClaw eine bidirektionale Brücke: Als Server exponiert es Kanäle/Approvals/Plugin-Werkzeuge an externe Clients; als Client importiert es Werkzeuge externer MCP-Server in die Werkzeug-Map der Session. OpenClawChannelBridge hält Gateway-Verbindung, Ereignis-Warteschlange und Pending-Approval-Tabelle und übersetzt Gateway-Ereignisse in MCP-Benachrichtigungen; createSessionMcpRuntime startet pro Konfigurationseintrag einen Client und zieht nach dem Connect die Werkzeugliste. Beide Seiten folgen dem Prinzip „Fehler in Stream/Warteschlange, nicht werfen" — Verbindungs- und listTools-Fehler werden in Diagnostics gewandelt, nicht als harte Fehler geworfen. Als Server exponierte Werkzeuge durchlaufen wrapToolWithBeforeToolCallHook — die gemeinsame Sicherheitsnaht mit der Agent-Hauptschleife, externe Clients dürfen Approval/Hooks nicht umgehen. MCP-Werkzeuge fließen letztlich in die Map des Werkzeugsystems ein, gleichberechtigt neben eingebauten und Plugin-Werkzeugen; wie das Laden von mcpServers beim Start der Session abläuft, ist in Embedded Runner erläutert.