The bridge advertised non-existent gateway actions ("click"/"type"), so Kimi
sent action="type" -> "Unknown action". Corrected to the real vocabulary:
interactions go through action="act" with args={kind, ref, text} (e.g.
{kind:"type", ref:"e59", text:"..."}, {kind:"click", ref}), plus the real
top-level actions (open/navigate/snapshot/screenshot/act/...). Verified Adolf
can now type/click in the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Qg9gi7H7HtT8MncoXAB
246 lines
13 KiB
JavaScript
246 lines
13 KiB
JavaScript
// openclaw-tools bridge (Adolf P5) — a minimal MCP server that proxies a
|
|
// small slice of the OpenClaw gateway's agent tools (message / cron / nodes /
|
|
// browser) to Kimi CLI sessions, over MCP Streamable HTTP.
|
|
//
|
|
// Why a bridge instead of pointing Kimi straight at the gateway: the gateway
|
|
// only speaks its own WS/HTTP protocol (`/tools/invoke`, docs/gateway/
|
|
// tools-invoke-http-api.md in the openclaw source), not MCP. This process is
|
|
// the thin translation layer: MCP tool call in, `POST {gateway}/tools/invoke`
|
|
// out, gateway JSON result back as MCP tool content.
|
|
//
|
|
// Gate (P6 dependency, verified against the openclaw source docs at
|
|
// /home/alvis/adolf/docs/gateway/tools-invoke-http-api.md): the gateway's
|
|
// `/tools/invoke` HTTP surface hard-denies `cron`, `gateway`, and `nodes` by
|
|
// default, and those three stay owner-only even if `gateway.tools.allow`
|
|
// re-enables them for non-owner callers. Shared-secret bearer auth (what this
|
|
// bridge uses) IS treated as a full owner/operator turn, so once P6 adds
|
|
// `gateway.tools.allow: ["cron", "nodes"]` (or similar) to the running
|
|
// adolf/openclaw.json, cron_create/cron_list/nodes_invoke below start working
|
|
// with no change here. `message` and `browser` are NOT in that default deny
|
|
// list, so message_send should work as soon as the gateway is up and its
|
|
// normal `tools.*` policy allows those tools for the caller — no special P6
|
|
// HTTP-deny override needed for those two.
|
|
//
|
|
// Until the `adolf` gateway container is actually configured and running
|
|
// (P6), every proxied call below will fail at the fetch() step (connection
|
|
// refused) — that is expected for P5 and is NOT a bug in this bridge. What
|
|
// P5 verifies is the MCP handshake + tool schemas themselves.
|
|
|
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
const { createMcpExpressApp } = require('@modelcontextprotocol/sdk/server/express.js');
|
|
const { z } = require('zod');
|
|
|
|
const PORT = Number(process.env.PORT) || 8020;
|
|
const HOST = '0.0.0.0';
|
|
|
|
// Gateway base URL: the `adolf` OpenClaw gateway container on the compose
|
|
// network (docker-compose.yml: ports 18789/18790, service name `adolf`).
|
|
const GATEWAY_BASE_URL = (process.env.OPENCLAW_GATEWAY_URL || 'http://adolf:18789').replace(/\/+$/, '');
|
|
// Shared-secret operator token. Accept either env name: OPENCLAW_GATEWAY_TOKEN
|
|
// is the name the `adolf` service reads OPENCLAW_GATEWAY_TOKEN from internally
|
|
// (docker-compose.yml sets it from ${ADOLF_GATEWAY_TOKEN:-}), so callers may
|
|
// reasonably set either var name for this bridge.
|
|
const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || process.env.ADOLF_GATEWAY_TOKEN || '';
|
|
const GATEWAY_TIMEOUT_MS = 20_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gateway proxy. POSTs to /tools/invoke (docs/gateway/tools-invoke-http-api.md):
|
|
// { tool, action, args, sessionKey?, agentId?, idempotencyKey?, dryRun? }
|
|
// `action` is optional and merges into args.action gateway-side when the
|
|
// tool schema supports it; we always send it at the top level to match the
|
|
// documented shape exactly.
|
|
// Returns MCP tool-result content. Network/HTTP/gateway errors are surfaced
|
|
// as `isError: true` tool content rather than thrown, so a dead/unconfigured
|
|
// gateway (expected pre-P6) never breaks the MCP connection itself.
|
|
async function invokeGatewayTool(tool, action, args) {
|
|
const body = { tool, args: args || {} };
|
|
if (action !== undefined) body.action = action;
|
|
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${GATEWAY_BASE_URL}/tools/invoke`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(GATEWAY_TOKEN ? { Authorization: `Bearer ${GATEWAY_TOKEN}` } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
});
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{
|
|
type: 'text',
|
|
text: `openclaw gateway unreachable at ${GATEWAY_BASE_URL} (tool=${tool}): ${err.message || err}`,
|
|
}],
|
|
};
|
|
}
|
|
|
|
const text = await resp.text();
|
|
let payload;
|
|
try { payload = JSON.parse(text); } catch { payload = { raw: text }; }
|
|
|
|
if (!resp.ok) {
|
|
const err = payload && payload.error;
|
|
const detail = err ? `${err.type || resp.status}: ${err.message || ''}` : `HTTP ${resp.status}`;
|
|
return {
|
|
isError: true,
|
|
content: [{ type: 'text', text: `gateway tool "${tool}" failed (${resp.status}): ${detail}` }],
|
|
};
|
|
}
|
|
|
|
return { content: [{ type: 'text', text: JSON.stringify(payload.result ?? payload, null, 2) }] };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
function buildServer() {
|
|
const server = new McpServer({ name: 'openclaw-tools', version: '1.0.0' });
|
|
|
|
// --- send-to-channel -------------------------------------------------
|
|
server.registerTool('message_send', {
|
|
description:
|
|
'Send a message to a chat channel through the OpenClaw gateway (Discord, Matrix, Telegram, Slack, WhatsApp, etc). ' +
|
|
'Proxies to the gateway "message" agent tool, action "send".',
|
|
inputSchema: {
|
|
to: z.string().describe(
|
|
'Delivery target. Format depends on channel: "!room:server" or "@user:server" (Matrix), ' +
|
|
'"channel:<id>" or "user:<id>" (Discord/Slack), "@username" or chat id (Telegram), E.164 (WhatsApp).',
|
|
),
|
|
channel: z.string().optional().describe(
|
|
'Channel provider id (matrix, telegram, discord, slack, whatsapp, ...). Required if more than one channel is configured.',
|
|
),
|
|
message: z.string().optional().describe('Message text.'),
|
|
media: z.string().optional().describe('Local path or URL of an image/audio/video/document to attach.'),
|
|
replyTo: z.string().optional().describe('Message id to reply to.'),
|
|
threadId: z.string().optional().describe('Thread or forum-topic id.'),
|
|
account: z.string().optional().describe('Account id, when the channel has multiple configured accounts.'),
|
|
},
|
|
}, async ({ to, channel, message, media, replyTo, threadId, account }) => {
|
|
const args = { to };
|
|
if (channel !== undefined) args.channel = channel;
|
|
if (message !== undefined) args.message = message;
|
|
if (media !== undefined) args.media = media;
|
|
if (replyTo !== undefined) args.replyTo = replyTo;
|
|
if (threadId !== undefined) args.threadId = threadId;
|
|
if (account !== undefined) args.account = account;
|
|
return invokeGatewayTool('message', 'send', args);
|
|
});
|
|
|
|
// --- cron / reminders --------------------------------------------------
|
|
const CRON_DENY_NOTE =
|
|
'NOTE: the gateway HTTP /tools/invoke surface hard-denies the "cron" tool by default ' +
|
|
'(persistent-automation control plane, owner-only) — this call 404s until the gateway operator ' +
|
|
'adds "cron" to gateway.tools.allow (Adolf P6). Field names mirror `openclaw cron <cmd>` CLI flags ' +
|
|
'in camelCase and are forwarded near-verbatim; treat gateway 400 error messages as the ground truth ' +
|
|
'for exact accepted fields.';
|
|
|
|
server.registerTool('cron_create', {
|
|
description: `Schedule a one-shot reminder or recurring job on the OpenClaw gateway cron scheduler. ${CRON_DENY_NOTE}`,
|
|
inputSchema: {
|
|
name: z.string().optional().describe('Job name.'),
|
|
at: z.string().optional().describe('One-shot: ISO 8601 timestamp or relative offset, e.g. "20m".'),
|
|
every: z.string().optional().describe('Recurring fixed interval, e.g. "10m", "1h", "1d".'),
|
|
cron: z.string().optional().describe('Recurring 5- or 6-field cron expression.'),
|
|
tz: z.string().optional().describe('IANA timezone for "at"/"cron" (default: gateway host tz / UTC).'),
|
|
session: z.enum(['main', 'isolated', 'current']).optional().describe('Execution style (default "main").'),
|
|
systemEvent: z.string().optional().describe('System-event text payload (no model call).'),
|
|
message: z.string().optional().describe('Agent-turn prompt payload (model-backed run).'),
|
|
wake: z.enum(['now', 'next-heartbeat']).optional().describe('Main-session wake mode.'),
|
|
deleteAfterRun: z.boolean().optional().describe('Auto-delete after a successful one-shot run.'),
|
|
announce: z.boolean().optional().describe('Deliver the result to a chat channel.'),
|
|
channel: z.string().optional().describe('Announce delivery channel.'),
|
|
to: z.string().optional().describe('Announce delivery target.'),
|
|
},
|
|
}, async (params) => invokeGatewayTool('cron', 'create', params));
|
|
|
|
server.registerTool('cron_list', {
|
|
description: `List jobs on the OpenClaw gateway cron scheduler. ${CRON_DENY_NOTE}`,
|
|
inputSchema: {
|
|
compact: z.boolean().optional().describe('Compact summaries (id, name, enabled, nextRunAtMs, ...). Default true.'),
|
|
},
|
|
}, async ({ compact }) => invokeGatewayTool('cron', 'list', { compact: compact ?? true }));
|
|
|
|
// --- nodes --------------------------------------------------------------
|
|
server.registerTool('nodes_invoke', {
|
|
description:
|
|
'Invoke a command on a paired OpenClaw node (camera, canvas, location, notify, screen record, etc). ' +
|
|
'NOTE: the gateway HTTP /tools/invoke surface hard-denies the "nodes" tool by default (node command ' +
|
|
'relay can reach system.run on paired hosts, owner-only) — this call 404s until the gateway operator ' +
|
|
'adds "nodes" to gateway.tools.allow (Adolf P6). `system.run`/`system.run.prepare` are blocked on this ' +
|
|
'path regardless; `system.which` is allowed.',
|
|
inputSchema: {
|
|
node: z.string().describe('Node id, display name, or IP.'),
|
|
command: z.string().describe('Node command, e.g. "canvas.eval", "location.get", "notify", "system.which".'),
|
|
params: z.record(z.string(), z.unknown()).optional().describe('Command-specific parameters object.'),
|
|
idempotencyKey: z.string().optional().describe('Optional idempotency key for the invoke.'),
|
|
},
|
|
}, async ({ node, command, params, idempotencyKey }) => {
|
|
const args = { node, command, params: params || {} };
|
|
if (idempotencyKey !== undefined) args.idempotencyKey = idempotencyKey;
|
|
return invokeGatewayTool('nodes', 'invoke', args);
|
|
});
|
|
|
|
// --- browser --------------------------------------------------------------
|
|
server.registerTool('browser_invoke', {
|
|
description:
|
|
'Drive the OpenClaw gateway "browser" tool (a headless Chromium the agent controls). ' +
|
|
'TOP-LEVEL actions (the "action" arg): status, start, stop, profiles, tabs, open, navigate, ' +
|
|
'snapshot, screenshot, act, console, dialog, pdf, upload. ' +
|
|
'IMPORTANT: there is NO top-level "click"/"type"/"fill"/"evaluate" action — ALL page ' +
|
|
'interactions go through action="act" with args={kind, ref, ...}. ' +
|
|
'Typical flow: action="open" {url} -> action="snapshot" to get element refs (e.g. "e59") -> ' +
|
|
'action="act" to interact. Interaction args examples: ' +
|
|
'type text -> {kind:"type", ref:"e59", text:"hello"}; ' +
|
|
'click -> {kind:"click", ref:"e33"}; ' +
|
|
'fill -> {kind:"fill", ref:"e65", text:"secret"}; ' +
|
|
'also kind can be hover|select|press|scrollIntoView|drag|evaluate. ' +
|
|
'Refs go stale after navigation/DOM change — if an action reports "ref not found", re-snapshot.',
|
|
inputSchema: {
|
|
action: z.string().describe('Top-level browser action: open | navigate | snapshot | screenshot | act | tabs | status | start | stop | profiles | console | dialog | pdf | upload. Use "act" for ALL page interactions (click/type/fill), never "click"/"type" directly.'),
|
|
args: z.record(z.string(), z.unknown()).optional().describe('Action params. action="open": {url, label?}. action="act": {kind:"type"|"click"|"fill"|"hover"|"select"|..., ref:"eN" from snapshot, text:"..." for type/fill}. action="snapshot": {refs:"aria"} for stable refs.'),
|
|
},
|
|
}, async ({ action, args }) => invokeGatewayTool('browser', action, args || {}));
|
|
|
|
return server;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stateless Streamable HTTP transport (mirrors the MCP SDK's own
|
|
// examples/server/simpleStatelessStreamableHttp.js): one McpServer + one
|
|
// transport per request, no session persistence needed for these tools.
|
|
const app = createMcpExpressApp({ host: HOST });
|
|
|
|
app.get('/health', (_req, res) => res.status(200).json({ ok: true }));
|
|
|
|
app.post('/mcp', async (req, res) => {
|
|
const server = buildServer();
|
|
try {
|
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
await server.connect(transport);
|
|
await transport.handleRequest(req, res, req.body);
|
|
res.on('close', () => {
|
|
transport.close();
|
|
server.close();
|
|
});
|
|
} catch (err) {
|
|
console.error('error handling MCP request:', err);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'internal server error' }, id: null });
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get('/mcp', (_req, res) => {
|
|
res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'method not allowed' }, id: null }));
|
|
});
|
|
|
|
app.delete('/mcp', (_req, res) => {
|
|
res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'method not allowed' }, id: null }));
|
|
});
|
|
|
|
app.listen(PORT, HOST, () => {
|
|
console.log(`openclaw-tools bridge listening on ${HOST}:${PORT} (gateway: ${GATEWAY_BASE_URL})`);
|
|
});
|