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 f43a5fd029
commit 4c204c9485
16 changed files with 457 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Enable Fusion tool-control support for the OpenClaw runtime plugin. OpenClaw sessions now derive custom tools from runtime session options, filter out built-in tools (`read`, `write`, `edit`, `bash`, `grep`, `find`), configure an MCP server via supported `openclaw mcp set` profile-based CLI flow, and pass that profile into `openclaw agent` calls while preserving default embedded `--local` behavior.

View File

@@ -621,29 +621,37 @@ fn plugin install ./plugins/fusion-plugin-openclaw-runtime
For more details, see the [Paperclip Runtime Plugin documentation](../plugins/fusion-plugin-paperclip-runtime/README.md), [Hermes Runtime Plugin documentation](../plugins/fusion-plugin-hermes-runtime/README.md), and [OpenClaw Runtime Plugin documentation](../plugins/fusion-plugin-openclaw-runtime/README.md). For more details, see the [Paperclip Runtime Plugin documentation](../plugins/fusion-plugin-paperclip-runtime/README.md), [Hermes Runtime Plugin documentation](../plugins/fusion-plugin-hermes-runtime/README.md), and [OpenClaw Runtime Plugin documentation](../plugins/fusion-plugin-openclaw-runtime/README.md).
### OpenClaw Gateway Configuration ### OpenClaw Runtime Configuration
The OpenClaw runtime plugin connects to a running OpenClaw gateway instance through its OpenAI-compatible HTTP API. You can configure the gateway connection using plugin settings or environment variables. The OpenClaw runtime plugin is CLI-first. Fusion invokes `openclaw agent --json` directly and defaults to embedded local mode (`--local`). Gateway mode is optional via `useGateway: true`.
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `gatewayUrl` | `string` | `http://127.0.0.1:18789` | URL of the OpenClaw gateway instance | | `binaryPath` | `string` | `openclaw` | Path to the OpenClaw binary. |
| `gatewayToken` | `string` | (none) | Authentication token for the gateway | | `agentId` | `string` | `"main"` | OpenClaw agent ID used for `--agent`. |
| `agentId` | `string` | `"main"` | OpenClaw agent ID to use for sessions | | `model` | `string` | (OpenClaw default) | Optional model override passed as `--model`. |
| `thinking` | `string` | `"off"` | Thinking level passed as `--thinking`. |
| `cliTimeoutSec` | `number` | `0` | OpenClaw-side timeout (`--timeout`, 0 = no OpenClaw timeout). |
| `cliTimeoutMs` | `number` | `300000` | Fusion-side hard kill timeout for each subprocess turn. |
| `useGateway` | `boolean` | `false` | When true, omit `--local` and allow OpenClaw's gateway path. |
| Setting | Environment Variable | Default if Unset | | Setting | Environment Variable | Default if Unset |
|---|---|---| |---|---|---|
| `gatewayUrl` | `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | | `binaryPath` | `OPENCLAW_BIN` | `openclaw` |
| `gatewayToken` | `OPENCLAW_GATEWAY_TOKEN` | (none — unauthenticated) |
| `agentId` | `OPENCLAW_AGENT_ID` | `main` | | `agentId` | `OPENCLAW_AGENT_ID` | `main` |
| `model` | `OPENCLAW_MODEL` | (OpenClaw default) |
| `thinking` | `OPENCLAW_THINKING` | `off` |
| `cliTimeoutSec` | `OPENCLAW_TIMEOUT_SEC` | `0` |
| `cliTimeoutMs` | `OPENCLAW_CLI_TIMEOUT_MS` | `300000` |
| `useGateway` | `OPENCLAW_USE_GATEWAY` | `false` |
Resolution priority is: plugin settings (`PluginContext.settings`) → environment variables → built-in defaults. Resolution priority is: plugin settings (`PluginContext.settings`) → environment variables → built-in defaults.
> These are **plugin-level** settings configured when the OpenClaw runtime plugin is installed/enabled (for example in the dashboard Plugin Manager or plugin config). They are not agent-level `runtimeConfig` fields. Agents only need `runtimeConfig.runtimeHint: "openclaw"`; gateway connection details are handled by the plugin. > These are **plugin-level** settings configured when the OpenClaw runtime plugin is installed/enabled. They are not agent-level `runtimeConfig` fields. Agents only need `runtimeConfig.runtimeHint: "openclaw"`.
> ⚠️ `gatewayToken` is a secret. Never log it or commit it to version control. For production, prefer setting `OPENCLAW_GATEWAY_TOKEN` in the environment. OpenClaw tool-control uses the supported MCP CLI surface (`openclaw mcp set` + profile-scoped `--profile` runs) when custom Fusion tools are present; built-ins (`read`, `write`, `edit`, `bash`, `grep`, `find`) remain filtered from that MCP bridge.
For additional gateway/runtime details, see the [OpenClaw Runtime Plugin documentation](../plugins/fusion-plugin-openclaw-runtime/README.md). For runtime details, see the [OpenClaw Runtime Plugin documentation](../plugins/fusion-plugin-openclaw-runtime/README.md).
--- ---

View File

@@ -180,6 +180,15 @@ describe("CLI bundle output", () => {
expect(manifest.name?.length).toBeGreaterThan(0); expect(manifest.name?.length).toBeGreaterThan(0);
}); });
it("dist/plugins/fusion-plugin-openclaw-runtime/ is staged with required bridge assets", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-openclaw-runtime");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(true);
});
it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => { it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime"); const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime");
const manifestPath = join(stagedRoot, "manifest.json"); const manifestPath = join(stagedRoot, "manifest.json");

View File

@@ -214,6 +214,13 @@ export default defineConfig({
logLevel: "warning", logLevel: "warning",
}); });
if (pluginId === "fusion-plugin-openclaw-runtime") {
const mcpServerAsset = join(pluginSrcDir, "src", "mcp-schema-server.cjs");
if (existsSync(mcpServerAsset)) {
cpSync(mcpServerAsset, join(pluginDestDir, "mcp-schema-server.cjs"));
}
}
console.log(`Bundled runtime plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`); console.log(`Bundled runtime plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
} }

View File

@@ -116,6 +116,11 @@ describe("OpenClaw runtime E2E pipeline", () => {
return; return;
} }
if (args.includes("mcp") && args.includes("set")) {
child.emit("close", 0);
return;
}
if (args.includes("agent") && args.includes("--json")) { if (args.includes("agent") && args.includes("--json")) {
const payload = JSON.stringify({ const payload = JSON.stringify({
payloads: [{ text: "OpenClaw response" }], payloads: [{ text: "OpenClaw response" }],
@@ -197,6 +202,13 @@ describe("OpenClaw runtime E2E pipeline", () => {
cwd: testRoot, cwd: testRoot,
systemPrompt: "You are helpful", systemPrompt: "You are helpful",
tools: "coding", tools: "coding",
customTools: [{
name: "fn_task_list",
label: "fn_task_list",
description: "list",
parameters: { type: "object" },
execute: vi.fn(),
} as any],
skills: ["bash"], skills: ["bash"],
}); });
@@ -209,6 +221,22 @@ describe("OpenClaw runtime E2E pipeline", () => {
"openclaw/openclaw-agent/openclaw/openclaw-agent", "openclaw/openclaw-agent/openclaw/openclaw-agent",
); );
expect(mockCreateFnAgent).not.toHaveBeenCalled(); expect(mockCreateFnAgent).not.toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls.map(([, args]) => args as string[]);
expect(spawnArgs.some((args) => args.includes("mcp") && args.includes("set"))).toBe(true);
expect(spawnArgs.some((args) => args.includes("agent") && args.includes("--profile"))).toBe(true);
});
it("keeps agent argv unchanged when no custom tools are provided", async () => {
const adapterModule = await import(pathToFileURL(openClawPluginModulePath()).href);
const adapter = new adapterModule.OpenClawRuntimeAdapter({ agentId: "openclaw-agent" });
const { session } = await adapter.createSession({ cwd: testRoot, systemPrompt: "sys" });
await adapter.promptWithFallback(session, "hello");
const agentCall = mockSpawn.mock.calls.find(([, args]) => (args as string[]).includes("agent"));
expect(agentCall).toBeTruthy();
const agentArgs = agentCall?.[1] as string[];
expect(agentArgs.includes("--profile")).toBe(false);
}); });
it("falls back to default pi runtime when OpenClaw plugin is not installed", async () => { it("falls back to default pi runtime when OpenClaw plugin is not installed", async () => {

View File

@@ -145,6 +145,14 @@ describe("OpenClaw runtime integration via engine resolution pipeline", () => {
getRuntimeById: vi.fn().mockReturnValue(registration), getRuntimeById: vi.fn().mockReturnValue(registration),
}); });
const customTool = {
name: "fn_task_show",
label: "fn_task_show",
description: "show",
parameters: { type: "object" },
execute: vi.fn(),
} as any;
const result = await createResolvedAgentSession({ const result = await createResolvedAgentSession({
sessionPurpose: "executor", sessionPurpose: "executor",
runtimeHint: "openclaw", runtimeHint: "openclaw",
@@ -152,6 +160,7 @@ describe("OpenClaw runtime integration via engine resolution pipeline", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
systemPrompt: "You are helpful", systemPrompt: "You are helpful",
tools: "coding", tools: "coding",
customTools: [customTool],
}); });
expect(result.runtimeId).toBe("openclaw"); expect(result.runtimeId).toBe("openclaw");
@@ -162,6 +171,7 @@ describe("OpenClaw runtime integration via engine resolution pipeline", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
systemPrompt: "You are helpful", systemPrompt: "You are helpful",
tools: "coding", tools: "coding",
customTools: [customTool],
}); });
}); });

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. 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 ## Prerequisites
```bash ```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. - **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. - **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. - **AbortSignal sends SIGTERM.** If the CLI ignores it (e.g. during a long model download), the hard-kill timer (`cliTimeoutMs`) eventually fires.
## Public API ## Public API

View File

@@ -12,6 +12,7 @@ import {
extractStderrError, extractStderrError,
promptCli, promptCli,
resolveCliConfig, resolveCliConfig,
configureOpenClawMcpServer,
} from "../pi-module.js"; } from "../pi-module.js";
import type { CliConfig, GatewaySession } from "../types.js"; import type { CliConfig, GatewaySession } from "../types.js";
@@ -169,23 +170,36 @@ describe("buildOpenClawArgs", () => {
}; };
it("puts --no-color first, then agent subcommand", () => { 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[0]).toBe("--no-color");
expect(args[1]).toBe("agent"); expect(args[1]).toBe("agent");
}); });
it("includes --local when useGateway is false", () => { 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"); expect(args).toContain("--local");
}); });
it("omits --local when useGateway is true", () => { 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"); 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", () => { 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("--json");
expect(args).toContain("--session-id"); expect(args).toContain("--session-id");
expect(args[args.indexOf("--session-id") + 1]).toBe("my-uuid"); expect(args[args.indexOf("--session-id") + 1]).toBe("my-uuid");
@@ -196,24 +210,24 @@ describe("buildOpenClawArgs", () => {
}); });
it("includes --model when configured", () => { 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).toContain("--model");
expect(args[args.indexOf("--model") + 1]).toBe("anthropic/claude-opus-4-5"); expect(args[args.indexOf("--model") + 1]).toBe("anthropic/claude-opus-4-5");
}); });
it("omits --model when not configured", () => { it("omits --model when not configured", () => {
const args = buildOpenClawArgs(baseConfig, "u", "p"); const args = buildOpenClawArgs(baseConfig, { sessionId: "u" }, "p");
expect(args).not.toContain("--model"); expect(args).not.toContain("--model");
}); });
it("includes --thinking with the configured level", () => { 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).toContain("--thinking");
expect(args[args.indexOf("--thinking") + 1]).toBe("high"); expect(args[args.indexOf("--thinking") + 1]).toBe("high");
}); });
it("includes --timeout with cliTimeoutSec", () => { 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).toContain("--timeout");
expect(args[args.indexOf("--timeout") + 1]).toBe("120"); 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", () => { describe("promptCli", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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, mockCreateCliSession,
mockPromptCli, mockPromptCli,
mockDescribeCliModel, mockDescribeCliModel,
mockConfigureOpenClawMcpServer,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockResolveCliConfig: vi.fn(), mockResolveCliConfig: vi.fn(),
mockCreateCliSession: vi.fn(), mockCreateCliSession: vi.fn(),
mockPromptCli: vi.fn(), mockPromptCli: vi.fn(),
mockDescribeCliModel: vi.fn(), mockDescribeCliModel: vi.fn(),
mockConfigureOpenClawMcpServer: vi.fn(),
})); }));
vi.mock("../pi-module.js", () => ({ vi.mock("../pi-module.js", () => ({
@@ -19,6 +21,7 @@ vi.mock("../pi-module.js", () => ({
createCliSession: mockCreateCliSession, createCliSession: mockCreateCliSession,
promptCli: mockPromptCli, promptCli: mockPromptCli,
describeCliModel: mockDescribeCliModel, describeCliModel: mockDescribeCliModel,
configureOpenClawMcpServer: mockConfigureOpenClawMcpServer,
})); }));
beforeEach(() => { beforeEach(() => {
@@ -41,6 +44,7 @@ beforeEach(() => {
callbacks, callbacks,
})); }));
mockDescribeCliModel.mockReturnValue("openclaw/main"); mockDescribeCliModel.mockReturnValue("openclaw/main");
mockConfigureOpenClawMcpServer.mockResolvedValue(undefined);
}); });
describe("OpenClawRuntimeAdapter — identity", () => { describe("OpenClawRuntimeAdapter — identity", () => {
@@ -58,6 +62,48 @@ describe("OpenClawRuntimeAdapter — identity", () => {
}); });
describe("OpenClawRuntimeAdapter — createSession", () => { 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 () => { it("delegates to createCliSession with systemPrompt + agentId + callbacks", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" }); const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
const onText = vi.fn(); const onText = vi.fn();

View File

@@ -82,8 +82,13 @@ export {
promptCli, promptCli,
describeCliModel, describeCliModel,
extractStderrError, extractStderrError,
configureOpenClawMcpServer,
} from "./pi-module.js"; } from "./pi-module.js";
export type { CliConfig, GatewaySession, OpenClawAgentJson } from "./types.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. // Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeOpenClawBinary } from "./probe.js"; 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 { spawn } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import type { import type {
CliConfig, CliConfig,
GatewayCallbacks, GatewayCallbacks,
@@ -101,15 +102,21 @@ export function resolveCliConfig(settings?: Record<string, unknown>): CliConfig
*/ */
export function buildOpenClawArgs( export function buildOpenClawArgs(
config: CliConfig, config: CliConfig,
sessionId: string, session: Pick<GatewaySession, "sessionId" | "mcpProfile">,
message: string, message: string,
): 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"); if (!config.useGateway) args.push("--local");
args.push("--json"); args.push("--json");
args.push("--session-id", sessionId); args.push("--session-id", session.sessionId);
args.push("--message", message); args.push("--message", message);
args.push("--agent", config.agentId); args.push("--agent", config.agentId);
@@ -152,6 +159,8 @@ export function createCliSession(opts: {
systemPrompt: string; systemPrompt: string;
agentId?: string; agentId?: string;
callbacks?: GatewayCallbacks; callbacks?: GatewayCallbacks;
mcpProfile?: string;
mcpConfigPath?: string;
}): GatewaySession { }): GatewaySession {
return { return {
sessionId: randomUUID(), sessionId: randomUUID(),
@@ -161,9 +170,44 @@ export function createCliSession(opts: {
lastModelDescription: `openclaw/${opts.agentId ?? DEFAULT_AGENT_ID}`, lastModelDescription: `openclaw/${opts.agentId ?? DEFAULT_AGENT_ID}`,
lastUsage: undefined, lastUsage: undefined,
callbacks: opts.callbacks, 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 // promptCli — spawns openclaw, parses the JSON, fires callbacks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -175,7 +219,7 @@ export async function promptCli(
callbacks?: GatewayCallbacks, callbacks?: GatewayCallbacks,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<void> { ): Promise<void> {
const args = buildOpenClawArgs(config, session.sessionId, message); const args = buildOpenClawArgs(config, session, message);
const cb: GatewayCallbacks = { ...session.callbacks, ...callbacks }; const cb: GatewayCallbacks = { ...session.callbacks, ...callbacks };
cb.onToolStart?.("openclaw.agent", { sessionId: session.sessionId }); cb.onToolStart?.("openclaw.agent", { sessionId: session.sessionId });

View File

@@ -20,11 +20,14 @@ import type {
GatewaySession, GatewaySession,
} from "./types.js"; } from "./types.js";
import { import {
configureOpenClawMcpServer,
createCliSession, createCliSession,
describeCliModel, describeCliModel,
promptCli, promptCli,
resolveCliConfig, resolveCliConfig,
} from "./pi-module.js"; } from "./pi-module.js";
import { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles, type ToolLike } from "./mcp-config.js";
import { randomUUID } from "node:crypto";
export class OpenClawRuntimeAdapter implements AgentRuntime { export class OpenClawRuntimeAdapter implements AgentRuntime {
readonly id = "openclaw"; readonly id = "openclaw";
@@ -37,9 +40,32 @@ export class OpenClawRuntimeAdapter implements AgentRuntime {
} }
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> { 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({ const session = createCliSession({
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
agentId: this.config.agentId, agentId: this.config.agentId,
mcpProfile,
mcpConfigPath,
callbacks: { callbacks: {
onText: options.onText, onText: options.onText,
onThinking: options.onThinking, onThinking: options.onThinking,

View File

@@ -53,6 +53,10 @@ export interface GatewaySession {
lastModelDescription: string; lastModelDescription: string;
/** Last-known token usage from the CLI JSON's `meta.agentMeta.usage`. */ /** Last-known token usage from the CLI JSON's `meta.agentMeta.usage`. */
lastUsage?: Record<string, number>; 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; callbacks?: GatewayCallbacks;
dispose?: () => Promise<void> | void; dispose?: () => Promise<void> | void;
} }