Skip to content

MCP: Bidirektionale Brücke

源码版本v2026.6.11

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):

typescript
/**
 * 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

Datenfluss

Auf der Server-Seite (channel-server.ts Assembly:L37-L60):

typescript
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):

typescript
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):

typescript
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:

typescript
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):

typescript
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-WebSocket sessions.subscribe abgeschlossen hat, bevor resolveReadyOnce (src/mcp/channel-bridge.ts:L410-L418) aufgerufen wird. Der externe Client ruft vor jedem Werkzeugaufruf waitUntilReady auf, 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, wird rejectReadyOnce nicht 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); sweepPendingExpired räumt regelmäßig auf; außerdem unref() 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: sendNotification fängt alle Fehler ab und schreibt nur eine Zeile nach stderr (src/mcp/channel-bridge.ts:L389-L408). Bei --verbose wird 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 in logDebug um. 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 wird catalog auf null gesetzt, catalogInFlight auf undefined und catalogInvalidationGeneration inkrementiert (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 retryAfterMs vermerkt (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 wrapToolWithBeforeToolCallHook umwickelt (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: toolRegistry ist 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.

Vergleich mit offiziellen Ressourcen: MCP-Doku · README.