MCP: pont bidirectionnel
Responsabilités
MCP (Model Context Protocol) est bidirectionnel dans OpenClaw: à la fois comme server pour exposer ses outils/canaux/événements d'approbation à un MCP client externe (Claude Code, Codex, Cursor, etc.), et comme client pour tirer les outils d'un MCP server externe dans l'ensemble d'outils de la boucle principale de l'agent. Les deux directions utilisent le même SDK (@modelcontextprotocol/sdk), mais les chemins d'implémentation sont totalement différents.
En mode server, OpenClaw lance un MCP server stdio qui expose les outils builtin, les outils de plugin, et les opérations de canal (envoyer un message, lister les sessions, lire l'historique, attendre une approbation) comme outils MCP; quand un client externe appelle ces outils, le server forward en interne vers Gateway RPC ou appelle directement l'exécuteur local. En mode client, OpenClaw connecte chaque server déclaré dans la config mcpServers via stdio/SSE/StreamableHTTP, et fusionne les outils exposés par l'autre côté dans la Map d'outils de la session agent — ces outils sont au même rang que les outils builtin et les outils de plugin.
La sous-responsabilité centrale du sous-système MCP est donc: adaptation de protocole (JSON-RPC <-> schéma MCP), cycle de vie (connexion, déconnexion, reconnexion, nettoyage idle), pontage d'outils (traduction de métadonnées et d'appels dans les deux sens), pontage d'événements (événements Gateway -> notification MCP; appels d'outils MCP -> Gateway RPC).
Motivation de conception
Pourquoi bidirectionnel? Parce que la posture d'OpenClaw est à la fois un harness d'agent (lui-même exécute des agents) et un fournisseur d'outils (laissant d'autres harness d'agent appeler ses capacités). Un agent externe comme Claude Code veut utiliser les capacités de canal d'OpenClaw (envoyer un Slack, lire l'historique iMessage); passer par MCP coûte bien moins cher que de faire réimplémenter cette adaptation à Claude Code. Réciproquement, l'agent d'OpenClaw lui-même veut utiliser les outils MCP exposés par d'autres (par exemple un MCP server de base de données); en mode client, ces outils apparaissent directement dans l'ensemble d'outils de l'agent.
OpenClawChannelBridge (channel-bridge.ts OpenClawChannelBridge:L68-L104) est le cœur du mode server. Il maintient une connexion WebSocket vers Gateway, traduit les événements Gateway (session.message, exec.approval.requested, plugin.approval.requested) en notifications MCP poussées au client externe; et forward les requêtes d'outils MCP initiées par le client externe (envoyer un message, lire l'historique, résoudre une approbation) en Gateway RPC. Le commentaire est explicite (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 » — ces trois choses sont toute la responsabilité du bridge. readyPromise garantit que Gateway a souscrit avant qu'un client externe n'appelle un outil; la queue de curseur (QUEUE_LIMIT=1000) garantit qu'aucun événement n'est perdu et qu'il est replayable; la table des approbations en attente garantit qu'une requête d'approbation ne soit pas manquée par le client externe.
Le cœur du mode client est resolveMcpTransport (mcp-transport.ts resolveMcpTransport:L89-L170) et createSessionMcpRuntime (agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555). Le premier résout une entrée mcpServers de la config en une instance SDK Transport; le second lance un Client par server, et après connect listTools récupère les métadonnées d'outils pour les fusionner dans l'ensemble d'outils de la session.
Le contrat StreamFunction impose « erreurs dans le stream, pas de throw »; MCP fait de même — le catch de createLazyStream encode l'échec de chargement de module en événement stream. Côté client MCP, on convertit les échecs de connexion et de listTools en diagnostics, sans throw qui bloquerait la session (src/agents/agent-bundle-mcp-runtime.ts:L547-L630).
Fichiers clés
channel-bridge.ts OpenClawChannelBridge:L68-L104— Bridge mode server; maintient connexion Gateway + queue d'événements + pending approval.channel-bridge.ts start:L111-L185— Démarre la connexion Gateway, ne resolve readyPromise qu'après ready.channel-bridge.ts handleGatewayEvent:L524-L569— Route les événements Gateway vers queue + notification.channel-bridge.ts handleSessionMessageEvent:L571-L633— Les messages utilisateur sont traduits ennotifications/claude/channelpour le client externe.channel-server.ts createOpenClawChannelMcpServer:L37-L60— Assemble McpServer + bridge + notification handler.tools-stdio-server.ts:L10-L49— Factory stdio server + shutdown de cycle de vie.plugin-tools-serve.ts createPluginToolsMcpServer:L57-L81— Expose les outils de plugin comme MCP server.plugin-tools-handlers.ts:L24-L73— HandlerslistTools/callTool, enveloppés du hook beforeToolCall.mcp-transport.ts resolveMcpTransport:L89-L170— En mode client, résout le transport stdio/SSE/StreamableHTTP.mcp-transport.ts attachStderrLogging:L34-L60— Convertit le stderr du server stdio en logs, pour que le bruit ne fuite pas vers le client.agent-bundle-mcp-runtime.ts createSessionMcpRuntime:L480-L555— En mode client, lance unClientpar server + connect.agent-bundle-mcp-runtime.ts cycle de vie connexion:L575-L625— Connexion, timeout, listTools, agrégation de capacités.agent-bundle-mcp-runtime.ts forward d'appel d'outil:L760-L831— Quand la session appelle un outil MCP, route selon serverName vers le client correspondant.
Flux de données
En mode server (channel-server.ts assemblage: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({ /* ... */ });
});Le bridge détient sa propre connexion WebSocket Gateway; quand un événement arrive, il entre dans 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 stocke la requête d'approbation dans pendingApprovals (avec TTL); le client externe peut interroger et répondre via les outils listPendingApprovals / respondToApproval. enqueue pousse l'événement dans la queue (au-delà de QUEUE_LIMIT=1000, on droppe le plus ancien) et réveille tous les waiters waitForEvent. handleSessionMessageEvent matche aussi les réponses utilisateur au format yes/no <code> (src/mcp/channel-bridge.ts:L586-L602) et les convertit en notifications/claude/channel/permission poussées au client externe — un design élégant qui ponte le mécanisme de permission de Claude Code vers les réponses par canal de chat.
En mode client (bundle-mcp connexion: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),
// ...
});Plusieurs designs: sanitize server name (sanitizeServerName) garantit que les préfixes de noms d'outils de différents servers ne collisionnent pas; écoute listChanged permet au server de pousser un changement de liste d'outils qui invalide le catalog local pour re-tirer; connectWithTimeout empêche un server lent de figer le démarrage; listAllToolsBestEffort dégrade quand le server ne supporte pas listChanged. Le cache au niveau session (sessions.get(serverName)) fait qu'un rechargement ne reconnecte pas.
L'entrée mode server qui expose les outils de plugin est dans 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) enregistre deux handlers ListToolsRequestSchema et CallToolRequestSchema; le premier retourne les métadonnées d'outils, le second passe par createPluginToolsMcpHandlers (src/mcp/plugin-tools-handlers.ts:L24-L73).
Le handler callTool a un design clé — chaque outil est enveloppé d'un hook beforeToolCall via wrapToolWithBeforeToolCallHook (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" });
});Le commentaire est explicite: « ACPX MCP bridge should enforce the same pre-execution hook boundary as the agent and HTTP tool execution paths » — un client externe qui appelle un outil doit passer par la même frontière beforeToolCall que la boucle principale de l'agent, sans court-circuiter approbation/hook sous prétexte « ça vient de MCP ». C'est un seam de sécurité.
Limites et modes d'échec
- Porte de ready:
OpenClawChannelBridge.start()doit attendre que le WebSocket Gateway ait finisessions.subscribeavant deresolveReadyOnce(src/mcp/channel-bridge.ts:L410-L418). Un client externe appelant un outil avant celawaitUntilReadyd'abord, sinon il manquerait des événements. - Retry de connexion initiale:
shouldRetryInitialMcpGatewayConnect(src/mcp/channel-bridge.ts:L650-L663) décide si l'erreur est retryable (gateway request timeout for connect/gateway connect challenge timeout); si oui, on nerejectReadyOncepas — cela permet au MCP server de survivre à une brève indisponibilité de Gateway pendant son redémarrage. - Queue limitée en longueur:
QUEUE_LIMIT = 1_000(src/mcp/channel-bridge.ts:L51-L58). En cas de dépassement, on droppe le plus ancien — pour éviter que la mémoire n'explose sur une session MCP longue. Les pending approvals ont aussi un TTL (60min / 30min),sweepPendingExpirednettoie périodiquement, etunref()l'interval pour qu'un process MCP stdio ne reste pas bloqué après la sortie du client (src/mcp/channel-bridge.ts:L482-L491). - Notification failure non throw:
sendNotificationcatch toutes les erreurs et n'écrit qu'une ligne stderr (src/mcp/channel-bridge.ts:L389-L408). En--verboseon affiche l'erreur détaillée, sinon une ligne only — pour éviter qu'une failure de notification ne cascade en impactant le flux principal. - Isolation stderr stdio:
attachStderrLogging(src/agents/mcp-transport.ts:L34-L60) convertit le stderr d'un MCP server stdio enlogDebug. Le protocole MCP stdio impose que stdout soit réservé au protocole; les logs vont en stderr. OpenClaw capture en plus le contenu de stderr dans son propre système de logs, pour empêcher le bruit de fuiter vers le client. - Sanitize server name:
sanitizeServerName(serverName, usedServerNames)(src/agents/agent-bundle-mcp-runtime.ts:L568-L573) ajoute un suffixe aux servers de même nom, pour garantir l'unicité des préfixes de noms d'outils. Un warn invite l'utilisateur à corriger sa config. - listChanged invalide le catalog: à l'événement
tools.listChanged, on metcatalogà null,catalogInFlightà undefined, et on incrémentecatalogInvalidationGeneration(src/agents/agent-bundle-mcp-runtime.ts:L590-L600). Le prochain appel d'outil re-tirera la liste. - Server suspendu en cas d'échec répété: un server dont les tool calls échouent à répétition est « suspendu », avec un retryAfterMs enregistré (
src/agents/agent-bundle-mcp-runtime.ts:L519-L520). Cela empêche un MCP server en panne de ralentir toute la boucle principale de l'agent. - Frontière beforeToolCall hook: en mode server exposant les outils, tous sont enveloppés via
wrapToolWithBeforeToolCallHook(src/mcp/plugin-tools-handlers.ts:L25-L32). Seam de sécurité: un client externe appelant un outil doit passer par la même frontière approbation/hook que la boucle principale de l'agent. - Après dispose tous les appels échouent:
failIfDisposed()est vérifié à chaque point clé (src/agents/agent-bundle-mcp-runtime.ts:L469-L470). Une fois la session fermée, tous les appels d'outils MCP lèvent directement, pour éviter qu'un client pendant ne provoque de fuite de ressources. - MCP n'est pas partie du système d'outils:
toolRegistryest la Map du système d'outils; les outils MCP pontés existent comme entrées de cette Map — ils sont au même rang que les outils builtin et les outils de plugin. Le sous-système MCP gère « connexion + traduction », pas « appel » — l'appel passe toujours par l'exécuteur de toolRegistry. Les outils exposés par un MCP server passent aussi par le même hook beforeToolCall que les plugins. L'interaction entre stdio d'un MCP server et la boucle principale de l'agent est expliquée dans Runner embarqué.
Résumé
MCP chez OpenClaw est un pont bidirectionnel: en mode server, expose les canaux/approbations/outils de plugin aux clients externes; en mode client, tire les outils d'un MCP server externe dans la Map d'outils de la session. OpenClawChannelBridge détient la connexion Gateway, la queue d'événements, la table des pending approvals, et traduit les événements Gateway en notifications MCP; createSessionMcpRuntime lance un Client par entrée de config, connecte, et tire la liste d'outils. Les deux côtés respectent « erreurs dans le stream/queue, pas de throw »; les échecs de connexion et de listTools sont convertis en diagnostics plutôt qu'en erreurs dures. Les outils exposés en mode server doivent passer par wrapToolWithBeforeToolCallHook — le seam de sécurité partagé avec la boucle principale de l'agent, sans bypass pour les clients externes. Les outils MCP finissent fusionnés dans la Map du système d'outils, au même rang que les outils builtin et les outils de plugins; le flux de chargement mcpServers au démarrage de session est expliqué dans Runner embarqué.
Pour comparer avec la documentation officielle: MCP docs · README.