feat(FN-3717): add OpenClaw MCP bridge (+5 more)

Commits merged:
- docs(FN-3717): update openclaw runtime and settings docs
- test(FN-3717): fix openclaw engine test typing
- test(FN-3717): strengthen openclaw bridge coverage
- feat(FN-3717): complete Step 2 — wire engine and bundle delivery
- fix(FN-3717): address openclaw bridge review feedback
- feat(FN-3717): complete Step 1 — add OpenClaw MCP bridge

Files changed:
.changeset/fn-3717-openclaw-tool-bridge.md         |  5 ++
 docs/settings-reference.md                         | 28 +++++---
 packages/cli/src/__tests__/bundle-output.test.ts   |  9 +++
 packages/cli/tsup.config.ts                        |  7 ++
 .../src/__tests__/openclaw-runtime-e2e.test.ts     | 28 ++++++++
 .../__tests__/openclaw-runtime-integration.test.ts | 10 +++
 plugins/fusion-plugin-openclaw-runtime/README.md   | 13 ++++
 .../src/__tests__/cli-spawn.test.ts                | 82 +++++++++++++++++++---
 .../src/__tests__/mcp-config.test.ts               | 45 ++++++++++++
 .../src/__tests__/runtime-adapter.test.ts          | 46 ++++++++++++
 .../fusion-plugin-openclaw-runtime/src/index.ts    |  5 ++
 .../src/mcp-config.ts                              | 60 ++++++++++++++++
 .../src/mcp-schema-server.cjs                      | 59 ++++++++++++++++
 .../src/pi-module.ts                               | 52 ++++++++++++--
 .../src/runtime-adapter.ts                         | 26 +++++++
 .../fusion-plugin-openclaw-runtime/src/types.ts    |  4 ++
 16 files changed, 457 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-3717
This commit is contained in:
Fusion
2026-05-07 17:56:43 -07:00
committed by gsxdsm
parent c32999b293
commit 836ca6807a
16 changed files with 457 additions and 22 deletions

View File

@@ -16,6 +16,18 @@ For each `promptWithFallback(session, prompt)`:
The previous HTTP `/v1/chat/completions` integration has been removed — that endpoint required a separate gateway daemon and was an OpenAI-compat shim. The CLI surface is the canonical OpenClaw API.
## Fusion tool-control (MCP bridge)
When a Fusion OpenClaw session includes custom tools, the runtime plugin now enables tool-control through OpenClaw's supported MCP configuration flow:
1. Collect session tools and filter out built-ins: `read`, `write`, `edit`, `bash`, `grep`, `find`.
2. Convert remaining tools into MCP-compatible schemas.
3. Write a temporary schema file and MCP server config (`node mcp-schema-server.cjs <schema.json>`).
4. Configure a profile-scoped MCP server using `openclaw --profile <id> mcp set fusion-custom-tools <json>`.
5. Spawn the agent turn with `openclaw --profile <id> agent ...` so OpenClaw can see the configured MCP server.
No private protocol is used — this is the verified OpenClaw CLI contract (`mcp set` + `--profile`).
## Prerequisites
```bash
@@ -46,6 +58,7 @@ Settings precedence: plugin settings → env var → default.
- **No per-token streaming.** `--json` emits a single JSON document at process exit. `onText` is called exactly once.
- **Default ignores the gateway.** With `useGateway: false` (default) we always pass `--local`, skipping the WebSocket connect attempt entirely. Most users want this.
- **Built-in tools are intentionally excluded from MCP bridge.** `read`, `write`, `edit`, `bash`, `grep`, and `find` stay native to Fusion and are not duplicated through OpenClaw MCP.
- **AbortSignal sends SIGTERM.** If the CLI ignores it (e.g. during a long model download), the hard-kill timer (`cliTimeoutMs`) eventually fires.
## Public API

View File

@@ -12,6 +12,7 @@ import {
extractStderrError,
promptCli,
resolveCliConfig,
configureOpenClawMcpServer,
} from "../pi-module.js";
import type { CliConfig, GatewaySession } from "../types.js";
@@ -169,23 +170,36 @@ describe("buildOpenClawArgs", () => {
};
it("puts --no-color first, then agent subcommand", () => {
const args = buildOpenClawArgs(baseConfig, "uuid-1", "hello");
const args = buildOpenClawArgs(baseConfig, { sessionId: "uuid-1" }, "hello");
expect(args[0]).toBe("--no-color");
expect(args[1]).toBe("agent");
});
it("includes --local when useGateway is false", () => {
const args = buildOpenClawArgs(baseConfig, "uuid-1", "hello");
const args = buildOpenClawArgs(baseConfig, { sessionId: "uuid-1" }, "hello");
expect(args).toContain("--local");
});
it("omits --local when useGateway is true", () => {
const args = buildOpenClawArgs({ ...baseConfig, useGateway: true }, "uuid-1", "hello");
const args = buildOpenClawArgs({ ...baseConfig, useGateway: true }, { sessionId: "uuid-1" }, "hello");
expect(args).not.toContain("--local");
});
it("includes --profile before agent when MCP profile is configured", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid", mcpProfile: "fusion-profile" }, "test prompt");
expect(args[0]).toBe("--no-color");
expect(args).toContain("--profile");
expect(args[args.indexOf("--profile") + 1]).toBe("fusion-profile");
expect(args.indexOf("--profile")).toBeLessThan(args.indexOf("agent"));
});
it("omits --profile when no MCP profile is configured", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid" }, "test prompt");
expect(args).not.toContain("--profile");
});
it("includes --json, --session-id, --message, --agent", () => {
const args = buildOpenClawArgs(baseConfig, "my-uuid", "test prompt");
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid" }, "test prompt");
expect(args).toContain("--json");
expect(args).toContain("--session-id");
expect(args[args.indexOf("--session-id") + 1]).toBe("my-uuid");
@@ -196,24 +210,24 @@ describe("buildOpenClawArgs", () => {
});
it("includes --model when configured", () => {
const args = buildOpenClawArgs({ ...baseConfig, model: "anthropic/claude-opus-4-5" }, "u", "p");
const args = buildOpenClawArgs({ ...baseConfig, model: "anthropic/claude-opus-4-5" }, { sessionId: "u" }, "p");
expect(args).toContain("--model");
expect(args[args.indexOf("--model") + 1]).toBe("anthropic/claude-opus-4-5");
});
it("omits --model when not configured", () => {
const args = buildOpenClawArgs(baseConfig, "u", "p");
const args = buildOpenClawArgs(baseConfig, { sessionId: "u" }, "p");
expect(args).not.toContain("--model");
});
it("includes --thinking with the configured level", () => {
const args = buildOpenClawArgs({ ...baseConfig, thinking: "high" }, "u", "p");
const args = buildOpenClawArgs({ ...baseConfig, thinking: "high" }, { sessionId: "u" }, "p");
expect(args).toContain("--thinking");
expect(args[args.indexOf("--thinking") + 1]).toBe("high");
});
it("includes --timeout with cliTimeoutSec", () => {
const args = buildOpenClawArgs({ ...baseConfig, cliTimeoutSec: 120 }, "u", "p");
const args = buildOpenClawArgs({ ...baseConfig, cliTimeoutSec: 120 }, { sessionId: "u" }, "p");
expect(args).toContain("--timeout");
expect(args[args.indexOf("--timeout") + 1]).toBe("120");
});
@@ -241,6 +255,58 @@ describe("extractStderrError", () => {
});
});
describe("configureOpenClawMcpServer", () => {
it("spawns `openclaw mcp set` with profile", async () => {
const child = makeFakeChild();
spawnMock.mockImplementation(() => {
setTimeout(() => child.emit("close", 0), 0);
return child;
});
const fs = await import("node:fs/promises");
const path = await import("node:path");
const os = await import("node:os");
const filePath = path.join(os.tmpdir(), `openclaw-mcp-config-${Date.now()}.json`);
await fs.writeFile(filePath, JSON.stringify({ command: "node", args: ["server.cjs", "schema.json"] }));
await configureOpenClawMcpServer({
binaryPath: "openclaw",
profile: "fusion-profile",
serverName: "fusion-custom-tools",
serverConfigPath: filePath,
});
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(args).toEqual(expect.arrayContaining(["--profile", "fusion-profile", "mcp", "set", "fusion-custom-tools"]));
});
it("throws when mcp set exits non-zero", async () => {
const child = makeFakeChild();
spawnMock.mockImplementation(() => {
setTimeout(() => {
child.stderr.emit("data", Buffer.from("bad config"));
child.emit("close", 1);
}, 0);
return child;
});
const fs = await import("node:fs/promises");
const path = await import("node:path");
const os = await import("node:os");
const filePath = path.join(os.tmpdir(), `openclaw-mcp-config-${Date.now()}-err.json`);
await fs.writeFile(filePath, JSON.stringify({ command: "node", args: ["server.cjs", "schema.json"] }));
await expect(
configureOpenClawMcpServer({
binaryPath: "openclaw",
profile: "fusion-profile",
serverName: "fusion-custom-tools",
serverConfigPath: filePath,
}),
).rejects.toThrow(/mcp set failed/);
});
});
describe("promptCli", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -0,0 +1,45 @@
import { readFileSync, unlinkSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles } from "../mcp-config.js";
const cleanupPaths: string[] = [];
afterEach(() => {
for (const path of cleanupPaths.splice(0)) {
try {
unlinkSync(path);
} catch {
// ignore
}
}
});
describe("toolsToMcpToolDefs", () => {
it("filters out built-in tools", () => {
const defs = toolsToMcpToolDefs([
{ name: "read", description: "builtin", parameters: { type: "object" } },
{ name: "fn_task_list", description: "list", parameters: { type: "object", properties: {} } },
]);
expect(defs).toHaveLength(1);
expect(defs[0]?.name).toBe("fn_task_list");
});
});
describe("writeOpenClawMcpBridgeFiles", () => {
it("writes schema and server config files", () => {
const bridge = writeOpenClawMcpBridgeFiles([
{ name: "fn_task_list", description: "list", inputSchema: { type: "object", properties: {} } },
], "test");
cleanupPaths.push(bridge.schemaPath, bridge.serverConfigPath);
const schema = JSON.parse(readFileSync(bridge.schemaPath, "utf-8"));
expect(Array.isArray(schema)).toBe(true);
expect(schema[0]?.name).toBe("fn_task_list");
const server = JSON.parse(readFileSync(bridge.serverConfigPath, "utf-8"));
expect(server.command).toBe("node");
expect(server.args[0]).toContain("mcp-schema-server.cjs");
expect(server.args[1]).toBe(bridge.schemaPath);
});
});

View File

@@ -7,11 +7,13 @@ const {
mockCreateCliSession,
mockPromptCli,
mockDescribeCliModel,
mockConfigureOpenClawMcpServer,
} = vi.hoisted(() => ({
mockResolveCliConfig: vi.fn(),
mockCreateCliSession: vi.fn(),
mockPromptCli: vi.fn(),
mockDescribeCliModel: vi.fn(),
mockConfigureOpenClawMcpServer: vi.fn(),
}));
vi.mock("../pi-module.js", () => ({
@@ -19,6 +21,7 @@ vi.mock("../pi-module.js", () => ({
createCliSession: mockCreateCliSession,
promptCli: mockPromptCli,
describeCliModel: mockDescribeCliModel,
configureOpenClawMcpServer: mockConfigureOpenClawMcpServer,
}));
beforeEach(() => {
@@ -41,6 +44,7 @@ beforeEach(() => {
callbacks,
}));
mockDescribeCliModel.mockReturnValue("openclaw/main");
mockConfigureOpenClawMcpServer.mockResolvedValue(undefined);
});
describe("OpenClawRuntimeAdapter — identity", () => {
@@ -58,6 +62,48 @@ describe("OpenClawRuntimeAdapter — identity", () => {
});
describe("OpenClawRuntimeAdapter — createSession", () => {
it("configures MCP bridge when custom tools are present", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
tools: [
{ name: "read", description: "builtin", parameters: { type: "object" } },
{ name: "fn_task_list", description: "list", parameters: { type: "object", properties: {} } },
],
});
expect(mockConfigureOpenClawMcpServer).toHaveBeenCalledOnce();
expect(mockCreateCliSession).toHaveBeenCalledWith(
expect.objectContaining({ mcpProfile: expect.stringContaining("fusion-") }),
);
});
it("configures MCP bridge when tools arrive through customTools", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
customTools: [{ name: "fn_task_show", description: "custom", parameters: { type: "object" } }],
});
expect(mockConfigureOpenClawMcpServer).toHaveBeenCalledOnce();
});
it("does not configure MCP bridge when only built-in tools are provided", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
tools: [{ name: "read", description: "builtin", parameters: { type: "object" } }],
});
expect(mockConfigureOpenClawMcpServer).not.toHaveBeenCalled();
expect(mockCreateCliSession).toHaveBeenCalledWith(
expect.objectContaining({ mcpProfile: undefined }),
);
});
it("delegates to createCliSession with systemPrompt + agentId + callbacks", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
const onText = vi.fn();

View File

@@ -82,8 +82,13 @@ export {
promptCli,
describeCliModel,
extractStderrError,
configureOpenClawMcpServer,
} from "./pi-module.js";
export type { CliConfig, GatewaySession, OpenClawAgentJson } from "./types.js";
export {
toolsToMcpToolDefs,
writeOpenClawMcpBridgeFiles,
} from "./mcp-config.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeOpenClawBinary } from "./probe.js";

View File

@@ -0,0 +1,60 @@
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const BUILT_IN_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "grep", "find"]);
export interface ToolLike {
name: string;
description?: string;
parameters?: Record<string, unknown>;
}
export interface McpToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface OpenClawMcpBridgeFiles {
schemaPath: string;
serverConfigPath: string;
serverName: string;
}
export function toolsToMcpToolDefs(tools: ReadonlyArray<ToolLike> | undefined): McpToolDef[] {
if (!Array.isArray(tools)) return [];
return tools
.filter((tool) => tool && typeof tool.name === "string" && !BUILT_IN_TOOL_NAMES.has(tool.name))
.map((tool) => ({
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
inputSchema: tool.parameters ?? { type: "object", properties: {} },
}));
}
export function writeOpenClawMcpBridgeFiles(toolDefs: McpToolDef[], cacheKey?: string): OpenClawMcpBridgeFiles {
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
const schemaPath = join(tmpdir(), `openclaw-runtime-mcp-schemas-${suffix}.json`);
writeFileSync(schemaPath, JSON.stringify(toolDefs));
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const serverPath = join(__dirname, "mcp-schema-server.cjs");
const serverConfig = {
command: "node",
args: [serverPath, schemaPath],
};
const serverConfigPath = join(tmpdir(), `openclaw-runtime-mcp-server-${suffix}.json`);
writeFileSync(serverConfigPath, JSON.stringify(serverConfig));
return {
schemaPath,
serverConfigPath,
serverName: "fusion-custom-tools",
};
}

View File

@@ -0,0 +1,59 @@
"use strict";
const fs = require("fs");
const readline = require("readline");
const schemaPath = process.argv[2];
if (!schemaPath) process.exit(1);
let tools = [];
try {
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
} catch {
process.exit(1);
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
if (msg.method === "initialize") {
process.stdout.write(
JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "fusion-custom-tools", version: "1.0.0" },
},
}) + "\n",
);
return;
}
if (msg.method === "tools/list") {
process.stdout.write(
JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { tools } }) + "\n",
);
return;
}
if (msg.method === "tools/call") {
process.stdout.write(
JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: {
content: [{ type: "text", text: "Tool execution is handled by Fusion runtime." }],
isError: true,
},
}) + "\n",
);
}
});

View File

@@ -10,6 +10,7 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import type {
CliConfig,
GatewayCallbacks,
@@ -101,15 +102,21 @@ export function resolveCliConfig(settings?: Record<string, unknown>): CliConfig
*/
export function buildOpenClawArgs(
config: CliConfig,
sessionId: string,
session: Pick<GatewaySession, "sessionId" | "mcpProfile">,
message: string,
): string[] {
const args: string[] = ["--no-color", "agent"];
const args: string[] = ["--no-color"];
if (session.mcpProfile) {
args.push("--profile", session.mcpProfile);
}
args.push("agent");
if (!config.useGateway) args.push("--local");
args.push("--json");
args.push("--session-id", sessionId);
args.push("--session-id", session.sessionId);
args.push("--message", message);
args.push("--agent", config.agentId);
@@ -152,6 +159,8 @@ export function createCliSession(opts: {
systemPrompt: string;
agentId?: string;
callbacks?: GatewayCallbacks;
mcpProfile?: string;
mcpConfigPath?: string;
}): GatewaySession {
return {
sessionId: randomUUID(),
@@ -161,9 +170,44 @@ export function createCliSession(opts: {
lastModelDescription: `openclaw/${opts.agentId ?? DEFAULT_AGENT_ID}`,
lastUsage: undefined,
callbacks: opts.callbacks,
mcpProfile: opts.mcpProfile,
mcpConfigPath: opts.mcpConfigPath,
};
}
// ---------------------------------------------------------------------------
// configureOpenClawMcpServer — configure profile-scoped MCP server via CLI
// ---------------------------------------------------------------------------
export async function configureOpenClawMcpServer(opts: {
binaryPath: string;
profile: string;
serverName: string;
serverConfigPath: string;
}): Promise<void> {
const serverValue = await readFile(opts.serverConfigPath, "utf-8");
await new Promise<void>((resolve, reject) => {
const child = spawn(opts.binaryPath, ["--no-color", "--profile", opts.profile, "mcp", "set", opts.serverName, serverValue], {
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err) => reject(new Error(`openclaw: failed to configure MCP server — ${err.message}`)));
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`openclaw: mcp set failed (${String(code)}): ${extractStderrError(stderr)}`));
});
});
}
// ---------------------------------------------------------------------------
// promptCli — spawns openclaw, parses the JSON, fires callbacks
// ---------------------------------------------------------------------------
@@ -175,7 +219,7 @@ export async function promptCli(
callbacks?: GatewayCallbacks,
signal?: AbortSignal,
): Promise<void> {
const args = buildOpenClawArgs(config, session.sessionId, message);
const args = buildOpenClawArgs(config, session, message);
const cb: GatewayCallbacks = { ...session.callbacks, ...callbacks };
cb.onToolStart?.("openclaw.agent", { sessionId: session.sessionId });

View File

@@ -20,11 +20,14 @@ import type {
GatewaySession,
} from "./types.js";
import {
configureOpenClawMcpServer,
createCliSession,
describeCliModel,
promptCli,
resolveCliConfig,
} from "./pi-module.js";
import { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles, type ToolLike } from "./mcp-config.js";
import { randomUUID } from "node:crypto";
export class OpenClawRuntimeAdapter implements AgentRuntime {
readonly id = "openclaw";
@@ -37,9 +40,32 @@ export class OpenClawRuntimeAdapter implements AgentRuntime {
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
const contextTools = [
...(Array.isArray(options.tools) ? (options.tools as ToolLike[]) : []),
...(Array.isArray(options.customTools) ? (options.customTools as ToolLike[]) : []),
];
const toolDefs = toolsToMcpToolDefs(contextTools);
let mcpProfile: string | undefined;
let mcpConfigPath: string | undefined;
if (toolDefs.length > 0) {
const bridge = writeOpenClawMcpBridgeFiles(toolDefs, randomUUID());
mcpProfile = `fusion-${randomUUID()}`;
mcpConfigPath = bridge.serverConfigPath;
await configureOpenClawMcpServer({
binaryPath: this.config.binaryPath,
profile: mcpProfile,
serverName: bridge.serverName,
serverConfigPath: bridge.serverConfigPath,
});
}
const session = createCliSession({
systemPrompt: options.systemPrompt,
agentId: this.config.agentId,
mcpProfile,
mcpConfigPath,
callbacks: {
onText: options.onText,
onThinking: options.onThinking,

View File

@@ -53,6 +53,10 @@ export interface GatewaySession {
lastModelDescription: string;
/** Last-known token usage from the CLI JSON's `meta.agentMeta.usage`. */
lastUsage?: Record<string, number>;
/** Optional profile that carries MCP server config for this session. */
mcpProfile?: string;
/** Optional path to the server JSON used to configure MCP for this session. */
mcpConfigPath?: string;
callbacks?: GatewayCallbacks;
dispose?: () => Promise<void> | void;
}