Skip to content

MCP: bidirectional bridge

源码版本v2026.6.11

Responsibilities

MCP (Model Context Protocol) in OpenClaw is bidirectional: it can act as a server to expose its own tools / channels / approval events to external MCP clients (Claude Code, Codex, Cursor, etc.), and as a client to pull in tools from external MCP servers into the agent main loop's tool set. Both directions use the same SDK (@modelcontextprotocol/sdk), but the implementation paths are completely different.

As a server, OpenClaw runs a stdio MCP server that wraps built-in tools, plugin tools, and channel operations (send message, list sessions, read history, await approval) as MCP tools exposed outward; when an external client calls these tools, the server forwards internally to Gateway RPC or directly to local tool execution. As a client, OpenClaw connects each server in the mcpServers config via stdio / SSE / StreamableHTTP and pulls the exposed tools into the agent session's tool Map — these tools sit alongside built-in and plugin tools as equals.

The MCP subsystem's core responsibilities are: protocol adaptation (JSON-RPC ↔ MCP schema), lifecycle (connect, disconnect, reconnect, idle cleanup), tool bridging (translate tool metadata and calls in both directions), and event bridging (Gateway events → MCP notification; MCP tool calls → Gateway RPC).

Design motivation

Why bidirectional? Because OpenClaw is positioned both as an agent harness (running its own agents) and as a tool provider (letting other agent harnesses call its capabilities). An external agent like Claude Code that wants OpenClaw's channel capabilities (send Slack, read iMessage history) finds it far cheaper to go through MCP than to implement its own adapter. Conversely, OpenClaw's own agents also want to use other people's MCP tools (like a database MCP server); acting as a client lets these tools appear directly in the agent's tool set.

OpenClawChannelBridge (channel-bridge.ts OpenClawChannelBridge:L68-L104) is the core of the server side. It maintains a WebSocket connection to the Gateway, translates Gateway events (session.message, exec.approval.requested, plugin.approval.requested) into MCP notifications pushed to external clients, and forwards requests initiated by external clients via MCP tool calls (send message, read history, resolve approval) into Gateway RPC. The comment is explicit (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" — these three are the bridge's full responsibilities. readyPromise ensures the Gateway has finished subscribing before any external client calls a tool; the cursor queue (QUEUE_LIMIT=1000) ensures events aren't lost and can be replayed; the pending approval table ensures approval requests aren't missed by external clients.

The client-side core is resolveMcpTransport (mcp-transport.ts resolveMcpTransport:L89-L170) and createSessionMcpRuntime (agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555). The former resolves each mcpServers config entry into an SDK Transport instance; the latter starts a Client per server, connects, and listTools pulls tool metadata into the session tool set.

The StreamFunction contract has an "errors go into the stream, not thrown" principle, and MCP follows the same pattern — createLazyStream's catch encodes module-load failures as stream events. The MCP client side turns connection failures and listTools failures into diagnostics rather than throwing and blocking the session (src/agents/agent-bundle-mcp-runtime.ts:L547-L630).

Key files

Data flow

Server side (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({ /* ... */ });
  });

The bridge owns the Gateway WebSocket connection; when an event arrives it goes to 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 stores the approval request in the pendingApprovals table (with TTL); external clients can query and respond through the listPendingApprovals / respondToApproval tools. enqueue pushes the event onto the queue (dropping the oldest when over QUEUE_LIMIT=1000) and wakes all waitForEvent waiters. handleSessionMessageEvent also matches user replies of the form yes/no <code> (src/mcp/channel-bridge.ts:L586-L602) and translates them into notifications/claude/channel/permission for external clients — an elegant bridge of Claude Code's permission mechanism to chat-channel replies.

Client side (bundle-mcp connect: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),
      // ...
    });

Note several design choices: server name sanitize (sanitizeServerName) ensures tool name prefixes don't collide across servers; listChanged listener lets the server push tool-list changes and OpenClaw invalidates its local catalog to refetch; connectWithTimeout prevents a slow server from stalling startup; listAllToolsBestEffort degrades gracefully when a server doesn't support listChanged. Per-session caching (sessions.get(serverName)) avoids reconnecting on repeated loads.

The entry point for exposing plugin tools as a server is 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) registers ListToolsRequestSchema and CallToolRequestSchema handlers. The former returns tool metadata; the latter goes through createPluginToolsMcpHandlers (src/mcp/plugin-tools-handlers.ts:L24-L73).

The callTool handler has a key design — every tool is wrapped with wrapToolWithBeforeToolCallHook (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" });
});

The comment is explicit: "ACPX MCP bridge should enforce the same pre-execution hook boundary as the agent and HTTP tool execution paths" — when an external client calls a tool, it must go through the same beforeToolCall boundary as the agent main loop, not bypass approval/hooks just because "it came through MCP". This is a security seam.

Boundaries and failures

  • readiness gate: OpenClawChannelBridge.start() must wait for the Gateway WebSocket to complete sessions.subscribe before calling resolveReadyOnce (src/mcp/channel-bridge.ts:L410-L418). Any external client calling a tool first waits on waitUntilReady, otherwise it would miss events.
  • retry initial connection: shouldRetryInitialMcpGatewayConnect (src/mcp/channel-bridge.ts:L650-L663) decides whether an error is retryable (gateway request timeout for connect / gateway connect challenge timeout); if retryable, it doesn't rejectReadyOnce — this lets the MCP server survive brief unreachability during Gateway restarts.
  • queue length cap: QUEUE_LIMIT = 1_000 (src/mcp/channel-bridge.ts:L51-L58). When the event queue overflows, the oldest entry is dropped — protecting long-running MCP session memory from blowing up. Pending approvals also have TTLs (60min / 30min); sweepPendingExpired periodically cleans up, and the interval is unref()-ed so the MCP stdio process doesn't hang after the client exits (src/mcp/channel-bridge.ts:L482-L491).
  • notification failures don't throw: sendNotification catches all errors and only writes one line to stderr (src/mcp/channel-bridge.ts:L389-L408). With --verbose it prints the detailed error; otherwise one line — preventing notification failures from cascading into the main flow.
  • stdio stderr isolation: attachStderrLogging (src/agents/mcp-transport.ts:L34-L60) turns the stdio MCP server's stderr into logDebug. The MCP stdio protocol requires stdout to be protocol-only, with any logs going to stderr; OpenClaw further captures stderr content into its own log system to prevent noise from reaching the client.
  • server name sanitize: sanitizeServerName(serverName, usedServerNames) (src/agents/agent-bundle-mcp-runtime.ts:L568-L573) adds a suffix to duplicate server names to keep tool name prefixes from colliding. It also warns the user to fix the config.
  • listChanged invalidates catalog: when tools.listChanged fires, catalog is set to null, catalogInFlight to undefined, and catalogInvalidationGeneration incremented (src/agents/agent-bundle-mcp-runtime.ts:L590-L600). The next tool call refetches the tool list.
  • paused server on failure: a server with repeated tool-call failures gets "paused", recording retryAfterMs (src/agents/agent-bundle-mcp-runtime.ts:L519-L520). This prevents one broken MCP server from slowing the whole agent main loop.
  • beforeToolCall hook boundary: when exposing tools as a server, all tools are wrapped with wrapToolWithBeforeToolCallHook (src/mcp/plugin-tools-handlers.ts:L25-L32). This is the security seam: external client tool calls must go through the same approval / hook boundary as the agent main loop.
  • post-dispose all calls fail: failIfDisposed() is checked at every key point (src/agents/agent-bundle-mcp-runtime.ts:L469-L470). After session close, all MCP tool calls throw, preventing dangling client calls from leaking resources.
  • MCP isn't part of the tool system: toolRegistry is the Map of the Tool system. MCP tools, once bridged in, exist as entries in this Map — they sit alongside built-in and plugin tools as equals. The MCP subsystem handles "connect + translate", not "invoke" — invocation still goes through toolRegistry's executor. Tools exposed by MCP servers also go through the same beforeToolCall hook as plugins. How the MCP stdio server startup interacts with the agent main loop is explained in Embedded runner.

Summary

MCP in OpenClaw is a bidirectional bridge: as a server it exposes channels / approvals / plugin tools to external clients; as a client it pulls external MCP servers' tools into the session tool Map. OpenClawChannelBridge holds the Gateway connection, event queue, and pending approval table, translating Gateway events into MCP notifications; createSessionMcpRuntime starts a Client per config entry, connects, and pulls the tool list. Both sides follow "errors go into the stream/queue, not thrown" — connection failures and listTools failures turn into diagnostics rather than hard errors. Tools exposed as a server must go through wrapToolWithBeforeToolCallHook — the shared security seam with the agent main loop, so external clients can't bypass approval/hooks. MCP tools ultimately merge into the Tool system Map, sitting alongside built-in and plugin tools; how session startup loads mcpServers is explained in Embedded runner.

Official references: MCP docs · README.