Skip to content

MCP: puente bidireccional

源码版本v2026.6.11

Responsabilidad

MCP (Model Context Protocol) en OpenClaw es bidireccional: puede actuar como server y exponer sus herramientas/canales/eventos de aprobación a un client MCP externo (Claude Code, Codex, Cursor, etc.), y también actuar como client trayendo al bucle principal del agent las herramientas de un MCP server externo. Ambas direcciones usan el mismo SDK (@modelcontextprotocol/sdk), pero las rutas de implementación son completamente distintas.

Como server, OpenClaw arranca un MCP server stdio que envuelve las herramientas builtin, las de plugin y las operaciones de canal (enviar mensaje, listar sesiones, leer historial, esperar aprobación) como herramientas MCP expuestas; cuando un client externo invoca estas herramientas, el server reenvía internamente a Gateway RPC o llama directamente al executor local de la herramienta. Como client, OpenClaw conecta cada server declarado en la config mcpServers vía stdio/SSE/StreamableHTTP y vuelca las herramientas que el otro expone al Map de herramientas de la sesión del agent — estas herramientas conviven en pie de igualdad con las builtin y las de plugin.

Las responsabilidades centrales del subsistema MCP son por tanto: adaptación de protocolo (JSON-RPC <-> schema MCP), ciclo de vida (conexión, desconexión, reconexión, limpieza por inactividad), puenteo de herramientas (traducción de metadatos/invocación de herramientas en ambas direcciones) y puenteo de eventos (eventos del Gateway -> notificaciones MCP; invocaciones de herramientas MCP -> Gateway RPC).

Motivación de diseño

¿Por qué bidireccional? Porque la posición de OpenClaw es a la vez un harness de agent (corre sus propios agents) y un proveedor de herramientas (deja que otros harnesses de agent invoquen sus capacidades). Un agent externo como Claude Code, si quiere usar las capacidades de canal de OpenClaw (enviar a Slack, leer el historial de iMessage), pasa por MCP mucho más barato que implementar su propia adaptación. Recíprocamente, el propio agent de OpenClaw también quiere usar herramientas MCP expuestas por terceros (por ejemplo, un MCP server de base de datos); como client, estas herramientas aparecen directamente en el conjunto de herramientas del agent.

OpenClawChannelBridge (channel-bridge.ts OpenClawChannelBridge:L68-L104) es el núcleo del lado server. Mantiene una conexión WebSocket al Gateway, traduce los eventos del Gateway (session.message, exec.approval.requested, plugin.approval.requested) en notificaciones MCP para el client externo, y reenvía las peticiones que el client externo hace vía invocación de tool MCP (enviar mensaje, leer historial, resolver aprobación) a Gateway RPC. El comentario lo deja claro (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» — estas tres cosas son toda la responsabilidad del bridge. readyPromise garantiza que el Gateway ha hecho subscribe antes de que un client externo invoque herramientas; la cola de cursor (QUEUE_LIMIT=1000) garantiza que los eventos no se pierden y se pueden reproducir; la tabla de pending approval garantiza que las peticiones de aprobación no se le escapan al client externo.

El núcleo del lado client es resolveMcpTransport (mcp-transport.ts resolveMcpTransport:L89-L170) y createSessionMcpRuntime (agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555). El primero resuelve las entradas mcpServers de la config en instancias SDK Transport; el segundo lanza un Client por server, conecta y luego listTools vuelca los metadatos de las herramientas al conjunto de herramientas de la sesión.

El contrato StreamFunction establece el principio de «los errores van al stream y no se lanzan»; en MCP es parecido — el catch de createLazyStream codifica los fallos de carga del módulo como eventos del stream. En el lado client de MCP, los fallos de conexión y de listTools se convierten en diagnostics, sin lanzar errores que bloqueen la sesión (src/agents/agent-bundle-mcp-runtime.ts:L547-L630).

Archivos clave

Flujo de datos

Como server (channel-server.ts monta: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({ /* ... */ });
  });

El bridge posee su conexión WebSocket al Gateway; cuando llega un evento lo pasa por 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 guarda la petición de aprobación en la tabla pendingApprovals (con TTL); el client externo puede consultar y responder mediante las herramientas listPendingApprovals / respondToApproval. enqueue empuja el evento a la cola (cuando se supera QUEUE_LIMIT=1000, se descarta el más antiguo) y despierta a todos los waiters de waitForEvent. handleSessionMessageEvent también empareja las respuestas de usuario con formato yes/no <code> (src/mcp/channel-bridge.ts:L586-L602) y las convierte en notifications/claude/channel/permission para el client externo — un diseño elegante que puentear el mecanismo de permisos de Claude Code hacia las respuestas en el canal de chat.

Como client (bundle-mcp conexión: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),
      // ...
    });

Varios diseños notables: sanitize de server name (sanitizeServerName) garantiza que los prefijos de nombre de herramienta de servers distintos no colisionen; el listener listChanged invalida el catalog local cuando el server empuja un cambio en la lista de herramientas; connectWithTimeout evita que un server lento bloquee el arranque; listAllToolsBestEffort degrada cuando el server no soporta listChanged. La caché a nivel de sesión (sessions.get(serverName)) evita reconectar en cargas repetidas.

La entrada del lado server para exponer herramientas de plugin está en 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) registra dos handlers, ListToolsRequestSchema y CallToolRequestSchema; el primero devuelve metadatos de herramientas, el segundo va por createPluginToolsMcpHandlers (src/mcp/plugin-tools-handlers.ts:L24-L73).

El handler callTool tiene un diseño clave — cada herramienta se envuelve con wrapToolWithBeforeToolCallHook para colgar el hook beforeToolCall (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" });
});

El comentario es explícito: «ACPX MCP bridge should enforce the same pre-execution hook boundary as the agent and HTTP tool execution paths» — cuando un client externo invoca una herramienta, debe pasar por el mismo límite beforeToolCall que el bucle principal del agent; no puede saltárselo por el mero hecho de venir de MCP. Es un seam de seguridad.

Límites y fallos

  • Puerta de readiness: OpenClawChannelBridge.start() debe esperar a que el WebSocket del Gateway complete sessions.subscribe antes de resolveReadyOnce (src/mcp/channel-bridge.ts:L410-L418). El client externo siempre llama waitUntilReady antes de invocar cualquier herramienta; en caso contrario, no recibiría eventos.
  • Reintento de conexión inicial: shouldRetryInitialMcpGatewayConnect (src/mcp/channel-bridge.ts:L650-L663) decide si un error es reintentable (gateway request timeout for connect / gateway connect challenge timeout); si lo es, no llama rejectReadyOnce — esto permite al MCP server aguantar caídas cortas del Gateway durante un reinicio.
  • Cola limitada: QUEUE_LIMIT = 1_000 (src/mcp/channel-bridge.ts:L51-L58). Cuando la cola de eventos se llena, se descarta el más antiguo — evita que una sesión MCP larga reviente la memoria. La pending approval tiene además un TTL (60min / 30min); sweepPendingExpired limpia periódicamente y se hace unref() del interval para que el proceso stdio de MCP no quede colgado tras la salida del client (src/mcp/channel-bridge.ts:L482-L491).
  • Las notificaciones fallidas no lanzan error: sendNotification captura todos los errores y solo escribe una línea a stderr (src/mcp/channel-bridge.ts:L389-L408). Con --verbose imprime el error completo; si no, una sola línea — evita que un fallo de notificación encadene efectos en el flujo principal.
  • Aislamiento del stderr stdio: attachStderrLogging (src/agents/mcp-transport.ts:L34-L60) convierte el stderr de un MCP server stdio en logDebug. El protocolo MCP stdio exige que stdout sea exclusivo del protocolo y cualquier log vaya a stderr; OpenClaw además captura el contenido del stderr en su propio sistema de logs, evitando que el ruido escape al client.
  • Saneamiento de server name: sanitizeServerName(serverName, usedServerNames) (src/agents/agent-bundle-mcp-runtime.ts:L568-L573) añade un sufijo a servers con el mismo nombre, garantizando que los prefijos de nombre de herramienta no colisionen. Avisa al usuario con un warn para que corrija la config.
  • listChanged invalida el catalog: al dispararse el evento tools.listChanged, catalog se pone a null, catalogInFlight a undefined y catalogInvalidationGeneration se autoincrementa (src/agents/agent-bundle-mcp-runtime.ts:L590-L600). La siguiente invocación de herramienta vuelve a tirar de la lista.
  • Server en pausa por fallos: cuando un server falla repetidamente en tool call, se le «pone en pausa» y se registra un retryAfterMs (src/agents/agent-bundle-mcp-runtime.ts:L519-L520). Evita que un MCP server caído ralentice todo el bucle principal del agent.
  • Límite del hook beforeToolCall: al exponer herramientas como server, todas se envuelven con wrapToolWithBeforeToolCallHook (src/mcp/plugin-tools-handlers.ts:L25-L32). Es un seam de seguridad: las invocaciones del client externo deben pasar por el mismo límite de aprobación/hook que el bucle principal del agent.
  • Tras dispose, todas las llamadas fallan: failIfDisposed() se comprueba en cada punto clave (src/agents/agent-bundle-mcp-runtime.ts:L469-L470). Tras cerrar la sesión, todas las invocaciones a herramientas MCP lanzan error, evitando que llamadas pendientes de un client colgante causen fugas de recursos.
  • MCP no es parte del sistema de herramientas: toolRegistry es el Map del sistema de herramientas; las herramientas MCP, tras puentearse, viven como entradas en ese Map — conviven en pie de igualdad con las herramientas builtin y las de plugin. El subsistema MCP se ocupa de «conectar + traducir», no de «invocar» — la invocación sigue siendo por el executor de toolRegistry. Las herramientas expuestas por el MCP server también pasan por el mismo hook beforeToolCall que registran los plugins. La interacción entre el arranque stdio del MCP server y el bucle principal del agent se explica en Runner embebido.

Resumen

MCP en OpenClaw es un puente bidireccional: como server expone canales/aprobación/herramientas de plugin a clientes externos; como client trae al Map de herramientas de la sesión las herramientas de un MCP server externo. OpenClawChannelBridge posee la conexión al Gateway, la cola de eventos y la tabla de pending approval, y traduce los eventos del Gateway en notificaciones MCP; createSessionMcpRuntime lanza un Client por cada entrada de config, conecta y tira de la lista de herramientas. Ambos lados respetan el principio de «los errores van al stream/cola y no se lanzan»; los fallos de conexión y de listTools se convierten en diagnostics, no en errores duros. Las herramientas expuestas como server deben pasar por wrapToolWithBeforeToolCallHook — un seam de seguridad compartido con el bucle principal del agent, que el client externo no puede saltarse. Las herramientas MCP al final se integran en el Map del sistema de herramientas, conviviendo con las herramientas builtin y las de plugins; el flujo de carga de mcpServers al arrancar la sesión se explica en Runner embebido.

Referencias oficiales: Documentación de MCP · README.