MCP: puente bidireccional
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):
/**
* 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
channel-bridge.ts OpenClawChannelBridge:L68-L104— bridge del lado server; mantiene la conexión al Gateway + cola de eventos + pending approval.channel-bridge.ts start:L111-L185— arranca la conexión al Gateway y resuelvereadyPromisecuando está listo.channel-bridge.ts handleGatewayEvent:L524-L569— enruta eventos del Gateway a la cola + notificación.channel-bridge.ts handleSessionMessageEvent:L571-L633— mensajes de usuario convertidos anotifications/claude/channelpara el client externo.channel-server.ts createOpenClawChannelMcpServer:L37-L60— monta McpServer + bridge + notification handler.tools-stdio-server.ts:L10-L49— factory del server stdio + shutdown del ciclo de vida.plugin-tools-serve.ts createPluginToolsMcpServer:L57-L81— expone las herramientas de plugin como un MCP server.plugin-tools-handlers.ts:L24-L73— handlerslistTools/callTool, envueltos con el hook beforeToolCall.mcp-transport.ts resolveMcpTransport:L89-L170— como client, resuelve transport stdio/SSE/StreamableHTTP.mcp-transport.ts attachStderrLogging:L34-L60— convierte el stderr del server stdio en logs, evitando que el ruido del stderr escape al client.agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555— como client, lanza unClientpor server + connect.agent-bundle-mcp-runtime.ts ciclo de vida de conexión:L575-L625— conexión, timeout, listTools, agregación de capacidades.agent-bundle-mcp-runtime.ts reenvío de invocación de herramienta:L760-L831— cuando la sesión invoca una herramienta MCP, enruta por serverName al client correspondiente.
Flujo de datos
Como server (channel-server.ts monta: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({ /* ... */ });
});El bridge posee su conexión WebSocket al Gateway; cuando llega un evento lo pasa por 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 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):
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:
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):
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 completesessions.subscribeantes deresolveReadyOnce(src/mcp/channel-bridge.ts:L410-L418). El client externo siempre llamawaitUntilReadyantes 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 llamarejectReadyOnce— 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);sweepPendingExpiredlimpia periódicamente y se haceunref()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:
sendNotificationcaptura todos los errores y solo escribe una línea a stderr (src/mcp/channel-bridge.ts:L389-L408). Con--verboseimprime 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 enlogDebug. 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,catalogse pone a null,catalogInFlighta undefined ycatalogInvalidationGenerationse 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:
toolRegistryes 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.