diff --git a/openai/adolf-llm/server.js b/openai/adolf-llm/server.js index 454ffed..742018b 100644 --- a/openai/adolf-llm/server.js +++ b/openai/adolf-llm/server.js @@ -19,21 +19,45 @@ fs.mkdirSync(STATE_DIR, { recursive: true }); // --------------------------------------------------------------------------- // Shared MCP layer (Gate 1). Kimi Code CLI has NO `--mcp-config-file` flag and -// no `kimi mcp` subcommand; it auto-discovers a project-root `.mcp.json` -// (Claude-Code-compatible schema) by walking up from its cwd. So we drop a -// `.mcp.json` into each session's working directory before spawning kimi. +// no `kimi mcp` subcommand; it auto-discovers a project-root `.mcp.json` by +// walking up from its cwd to the nearest `.git` (falling back to cwd itself +// when none is found). So we drop a `.mcp.json` into each session's working +// directory before spawning kimi. // -// STUB: the server list is empty for now. cognee-mcp (memory_search/add/cognify, -// P4) and the openclaw-tools bridge (P5) do not exist yet. Wiring them later is -// a one-line change here — add entries to SHARED_MCP_SERVERS and every future -// session dir picks them up automatically. Schema per server, e.g.: -// cognee: { command, args, env } (stdio) -// openclaw-tools:{ type: "sse"|"http", url, headers } (remote) -// TODO(P4/P5): populate SHARED_MCP_SERVERS from cognee-mcp + openclaw-tools. -const SHARED_MCP_SERVERS = { - // TODO(P4): "cognee": { command: "...", args: [...], env: {...} }, - // TODO(P5): "openclaw-tools": { type: "sse", url: "http://openclaw:.../mcp" }, -}; +// Single source of truth: `/shared-mcp.json` (mounted read-only from the repo +// root's `shared-mcp.json`, the same file P6 wires into OpenClaw's own +// `mcp.servers` registry). Adding a server is then a one-file change — no +// server list is hardcoded here anymore. +// +// Gate-1 transport finding (P5, verified by decompiling the installed +// @moonshot-ai/kimi-code package, packages/agent-core/src/config/schema.ts's +// McpServerConfigSchema): Kimi's own field name for remote MCP servers is +// `transport` (literal "stdio" | "http" | "sse"), not `type`. When `transport` +// is omitted, Kimi's config preprocessor infers it from shape: `command` -> +// "stdio", `url` -> "http" (never "sse" — sse requires an explicit +// `transport: "sse"`). It does NOT recognize a `type` key at all; unknown keys +// are silently stripped by the (non-strict) zod schema. +// OpenClaw's own canonical `mcp.servers` schema (docs/gateway/ +// configuration-reference.md) uses different literals for the same +// transport: `transport: "streamable-http"` or `"sse"`, with `type: "http"` +// documented as a *CLI-native alias* that `openclaw mcp set` / `openclaw +// doctor --fix` normalize into canonical `transport: "streamable-http"`. +// So the two consumers disagree on the literal value for HTTP streaming +// ("http" vs "streamable-http") under the same field name `transport` -- +// writing `transport` explicitly in shared-mcp.json would satisfy at most one +// side. `type: "http"` is the one shape both sides tolerate today: Kimi +// ignores the unrecognized `type` key and correctly infers transport "http" +// from the `url` field alone; OpenClaw recognizes `type` as its documented +// alias and normalizes it on its own terms (P6 concern, not touched here). +// Hence shared-mcp.json intentionally keeps `"type": "http"` for both cognee +// and openclaw-tools rather than switching to `transport`. +let SHARED_MCP_SERVERS = {}; +try { + const raw = fs.readFileSync('/shared-mcp.json', 'utf8'); + SHARED_MCP_SERVERS = JSON.parse(raw).mcpServers || {}; +} catch (err) { + console.error(`shared-mcp.json not loaded (${err.message}); sessions will get no shared MCP servers`); +} function writeMcpConfig(dir) { const cfg = { mcpServers: SHARED_MCP_SERVERS }; diff --git a/openai/docker-compose.yml b/openai/docker-compose.yml index 6d61b93..0edd0b6 100644 --- a/openai/docker-compose.yml +++ b/openai/docker-compose.yml @@ -169,8 +169,9 @@ services: # adolf-llm — conversational Kimi-CLI wrapper (:8010), the model backend for # the Adolf OpenClaw gateway (P2). Real streaming (SSE), chat_id session-keying - # + 1:1 kimi resume, media, per-session .mcp.json (shared-MCP servers stubbed - # until P4/P5). Needs `kimi login` in adolf-llm-home. + # + 1:1 kimi resume, media, per-session .mcp.json sourced from the shared + # shared-mcp.json contract (cognee-mcp P4, openclaw-tools P5). Needs + # `kimi login` in adolf-llm-home. adolf-llm: build: ./adolf-llm container_name: adolf-llm @@ -179,6 +180,7 @@ services: volumes: - adolf-llm-workspace:/workspace - adolf-llm-home:/root/.kimi-code + - ./shared-mcp.json:/shared-mcp.json:ro restart: unless-stopped # cognee — Adolf's memory backend (P4). FastAPI + embedded Kuzu graph + @@ -238,6 +240,25 @@ services: depends_on: - cognee + # openclaw-tools — MCP bridge (P5) exposing a minimal slice of the Adolf + # OpenClaw gateway's agent tools (message/cron/nodes/browser) over MCP + # Streamable HTTP, so Kimi CLI sessions (adolf-llm) can call them instead of + # bypassing OpenClaw entirely. Proxies each MCP tool call to the gateway's + # `POST /tools/invoke` HTTP surface (http://adolf:18789). NOTE: `cron` and + # `nodes` are hard-denied on that surface by default until P6 adds them to + # `gateway.tools.allow` in the adolf openclaw.json — see openclaw-tools/ + # server.js for the full gate writeup. Not useful until `adolf` (P6) is + # configured and running; safe to build/run standalone before that. + openclaw-tools: + build: ./openclaw-tools + container_name: openclaw-tools + environment: + - OPENCLAW_GATEWAY_URL=http://adolf:18789 + - OPENCLAW_GATEWAY_TOKEN=${ADOLF_GATEWAY_TOKEN:-} + ports: + - "8020:8020" + restart: unless-stopped + volumes: kimi-agent-home: adolf-state: diff --git a/openai/openclaw-tools/Dockerfile b/openai/openclaw-tools/Dockerfile new file mode 100644 index 0000000..6f3ed98 --- /dev/null +++ b/openai/openclaw-tools/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-slim + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY server.js ./ + +EXPOSE 8020 + +ENTRYPOINT ["node", "/app/server.js"] diff --git a/openai/openclaw-tools/package.json b/openai/openclaw-tools/package.json new file mode 100644 index 0000000..2befd51 --- /dev/null +++ b/openai/openclaw-tools/package.json @@ -0,0 +1,11 @@ +{ + "name": "openclaw-tools-bridge", + "private": true, + "version": "1.0.0", + "description": "MCP bridge exposing a minimal slice of the OpenClaw gateway's agent tools (message/cron/nodes/browser) over Streamable HTTP, for Kimi CLI sessions (adolf-llm) via shared-mcp.json.", + "main": "server.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } +} diff --git a/openai/openclaw-tools/server.js b/openai/openclaw-tools/server.js new file mode 100644 index 0000000..b9d2e59 --- /dev/null +++ b/openai/openclaw-tools/server.js @@ -0,0 +1,239 @@ +// 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:" or "user:" (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 ` 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: + 'Generic pass-through to the OpenClaw gateway "browser" agent tool (agent-controlled Chrome/Brave/Edge ' + + 'automation: tabs, snapshot, click, type, screenshot). Unlike cron/nodes, "browser" is NOT in the ' + + 'gateway HTTP default hard-deny list, so this can work as soon as the gateway is up and its normal ' + + 'tools.* policy allows "browser" for the caller — no special P6 HTTP-deny override needed. Args are ' + + 'forwarded verbatim as the tool call payload; the exact action/argument vocabulary is defined by the ' + + 'running gateway and needs live introspection once it exists (deferred to P6/P7).', + inputSchema: { + action: z.string().describe('Browser tool action, e.g. "status", "open", "snapshot", "click", "type".'), + args: z.record(z.string(), z.unknown()).optional().describe('Action-specific parameters object.'), + }, + }, 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})`); +}); diff --git a/openai/shared-mcp.json b/openai/shared-mcp.json index e8db87d..317886f 100644 --- a/openai/shared-mcp.json +++ b/openai/shared-mcp.json @@ -3,6 +3,10 @@ "cognee": { "type": "http", "url": "http://cognee-mcp:8000/mcp" + }, + "openclaw-tools": { + "type": "http", + "url": "http://openclaw-tools:8020/mcp" } } }