fix: harden ACP plugin tools bridge (#56867) (thanks @joe2643)

This commit is contained in:
Peter Steinberger
2026-03-29 21:03:43 +01:00
parent e24091413c
commit 73477eee4c
14 changed files with 612 additions and 47 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.
- MCP: add remote HTTP/SSE server support for `mcp.servers` URL configs, including auth headers and safer config redaction for MCP credentials. (#50396) Thanks @dhananjai1729.
- Plugins/hooks: add a `before_install` hook with structured request provenance, built-in scan status, and install-target metadata so external security scanners and policy engines can review and block skill, plugin package, plugin bundle, and single-file plugin installs. (#56050) thanks @odysseus0.
- ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.
### Fixes

View File

@@ -147,6 +147,10 @@ Per-session `mcpServers` are not supported in bridge mode. If an ACP client
sends them during `newSession` or `loadSession`, the bridge returns a clear
error instead of silently ignoring them.
If you want ACPX-backed sessions to see OpenClaw plugin tools, enable the
gateway-side ACPX plugin bridge instead of trying to pass per-session
`mcpServers`. See [ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge).
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your

View File

@@ -672,6 +672,37 @@ Notes:
See [Plugins](/tools/plugin).
### Plugin tools MCP bridge
By default, ACPX sessions do **not** expose OpenClaw plugin-registered tools to
the ACP harness.
If you want ACP agents such as Codex or Claude Code to call installed
OpenClaw plugin tools such as memory recall/store, enable the dedicated bridge:
```bash
openclaw config set plugins.entries.acpx.config.pluginToolsMcpBridge true
```
What this does:
- Injects a built-in MCP server named `openclaw-plugin-tools` into ACPX session
bootstrap.
- Exposes plugin tools already registered by installed and enabled OpenClaw
plugins.
- Keeps the feature explicit and default-off.
Security and trust notes:
- This expands the ACP harness tool surface.
- ACP agents get access only to plugin tools already active in the gateway.
- Treat this as the same trust boundary as letting those plugins execute in
OpenClaw itself.
- Review installed plugins before enabling it.
Custom `mcpServers` still work as before. The built-in plugin-tools bridge is an
additional opt-in convenience, not a replacement for generic MCP server config.
## Permission configuration
ACP sessions run non-interactively — there is no TTY to approve or deny file-write and shell-exec permission prompts. The acpx plugin provides two config keys that control how permissions are handled:

View File

@@ -27,6 +27,9 @@
"type": "string",
"enum": ["deny", "fail"]
},
"pluginToolsMcpBridge": {
"type": "boolean"
},
"strictWindowsCmdWrapper": {
"type": "boolean"
},
@@ -85,6 +88,11 @@
"label": "Non-Interactive Permission Policy",
"help": "acpx policy when interactive permission prompts are unavailable."
},
"pluginToolsMcpBridge": {
"label": "Plugin Tools MCP Bridge",
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into ACPX sessions so ACP agents can call plugin-registered tools.",
"advanced": true
},
"strictWindowsCmdWrapper": {
"label": "Strict Windows cmd Wrapper",
"help": "Enabled by default. On Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Disable only for compatibility with non-standard wrappers.",

View File

@@ -9,10 +9,12 @@ import {
} from "../../../test/helpers/bundled-plugin-paths.js";
import {
ACPX_BUNDLED_BIN,
ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME,
ACPX_PINNED_VERSION,
createAcpxPluginConfigSchema,
resolveAcpxPluginRoot,
resolveAcpxPluginConfig,
resolvePluginToolsMcpServerConfig,
} from "./config.js";
describe("acpx plugin config parsing", () => {
@@ -75,6 +77,8 @@ describe("acpx plugin config parsing", () => {
expect(resolved.allowPluginLocalInstall).toBe(true);
expect(resolved.stripProviderAuthEnvVars).toBe(true);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
expect(resolved.pluginToolsMcpBridge).toBe(false);
expect(resolved.mcpServers).toEqual({});
expect(resolved.strictWindowsCmdWrapper).toBe(true);
});
@@ -170,6 +174,79 @@ describe("acpx plugin config parsing", () => {
expect(parsed.success).toBe(false);
});
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-plugin-tools-dist-"));
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
const distEntry = path.join(repoRoot, "dist", "mcp", "plugin-tools-serve.js");
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.mkdirSync(path.dirname(distEntry), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
fs.writeFileSync(distEntry, "// built entry\n", "utf8");
const resolved = resolveAcpxPluginConfig({
rawConfig: {
pluginToolsMcpBridge: true,
},
workspaceDir: repoRoot,
moduleUrl: pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href,
});
expect(resolved.pluginToolsMcpBridge).toBe(true);
expect(resolved.mcpServers[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]).toEqual({
command: process.execPath,
args: [distEntry],
});
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("falls back to the source plugin-tools MCP server entry when dist is absent", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-plugin-tools-src-"));
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
const sourceConfigUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "src", "mcp"), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
fs.writeFileSync(
path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts"),
"// test\n",
"utf8",
);
expect(resolvePluginToolsMcpServerConfig(sourceConfigUrl)).toEqual({
command: process.execPath,
args: ["--import", "tsx", path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts")],
});
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("rejects reserved MCP server name collisions when the plugin-tools bridge is enabled", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
pluginToolsMcpBridge: true,
mcpServers: {
[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]: {
command: "node",
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toThrow(
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
);
});
it("accepts strictWindowsCmdWrapper override", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {

View File

@@ -12,6 +12,7 @@ export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
export const ACPX_VERSION_ANY = "any";
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
function isAcpxPluginRoot(dir: string): boolean {
@@ -90,6 +91,7 @@ export type AcpxPluginConfig = {
cwd?: string;
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge?: boolean;
strictWindowsCmdWrapper?: boolean;
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
@@ -105,6 +107,7 @@ export type ResolvedAcpxPluginConfig = {
cwd: string;
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge: boolean;
strictWindowsCmdWrapper: boolean;
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
@@ -155,6 +158,7 @@ const AcpxPluginConfigSchema = z.strictObject({
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
})
.optional(),
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
strictWindowsCmdWrapper: z
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
.optional(),
@@ -208,6 +212,57 @@ function resolveConfiguredCommand(params: { configured?: string; workspaceDir?:
return configured;
}
function resolveOpenClawRoot(currentRoot: string): string {
if (
path.basename(currentRoot) === "acpx" &&
path.basename(path.dirname(currentRoot)) === "extensions"
) {
const parent = path.dirname(path.dirname(currentRoot));
if (path.basename(parent) === "dist") {
return path.dirname(parent);
}
return parent;
}
return path.resolve(currentRoot, "..");
}
export function resolvePluginToolsMcpServerConfig(
moduleUrl: string = import.meta.url,
): McpServerConfig {
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
const openClawRoot = resolveOpenClawRoot(pluginRoot);
const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js");
if (fs.existsSync(distEntry)) {
return {
command: process.execPath,
args: [distEntry],
};
}
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
return {
command: process.execPath,
args: ["--import", "tsx", sourceEntry],
};
}
function resolveConfiguredMcpServers(params: {
mcpServers?: Record<string, McpServerConfig>;
pluginToolsMcpBridge: boolean;
moduleUrl?: string;
}): Record<string, McpServerConfig> {
const resolved = { ...(params.mcpServers ?? {}) };
if (!params.pluginToolsMcpBridge) {
return resolved;
}
if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
throw new Error(
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
);
}
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl);
return resolved;
}
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
return buildPluginConfigSchema(AcpxPluginConfigSchema);
}
@@ -227,6 +282,7 @@ export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): Ac
export function resolveAcpxPluginConfig(params: {
rawConfig: unknown;
workspaceDir?: string;
moduleUrl?: string;
}): ResolvedAcpxPluginConfig {
const parsed = parseAcpxPluginConfig(params.rawConfig);
if (!parsed.ok) {
@@ -247,6 +303,12 @@ export function resolveAcpxPluginConfig(params: {
? undefined
: (configuredExpectedVersion ?? (allowPluginLocalInstall ? ACPX_PINNED_VERSION : undefined));
const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION);
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
const mcpServers = resolveConfiguredMcpServers({
mcpServers: normalized.mcpServers,
pluginToolsMcpBridge,
moduleUrl: params.moduleUrl,
});
return {
command,
@@ -258,10 +320,11 @@ export function resolveAcpxPluginConfig(params: {
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
pluginToolsMcpBridge,
strictWindowsCmdWrapper:
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
timeoutSeconds: normalized.timeoutSeconds,
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
mcpServers: normalized.mcpServers ?? {},
mcpServers,
};
}

View File

@@ -1,7 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { resolveAcpxPluginConfig } from "./config.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
cleanupMockRuntimeFixtures,
@@ -24,6 +27,7 @@ beforeAll(async () => {
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
pluginToolsMcpBridge: false,
strictWindowsCmdWrapper: true,
queueOwnerTtlSeconds: 0.1,
mcpServers: {},
@@ -612,6 +616,72 @@ describe("AcpxRuntime", () => {
}
});
it("routes ACPX commands through the built-in plugin-tools bridge only when explicitly enabled", async () => {
process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS = JSON.stringify({
codex: {
command: "npx custom-codex-acp",
},
});
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-acpx-plugin-tools-"));
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
const distEntry = path.join(repoRoot, "dist", "mcp", "plugin-tools-serve.js");
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.mkdirSync(path.dirname(distEntry), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
fs.writeFileSync(distEntry, "// built entry\n", "utf8");
const fixture = await createMockRuntimeFixture();
const runtime = new AcpxRuntime(
resolveAcpxPluginConfig({
rawConfig: {
command: fixture.config.command,
pluginToolsMcpBridge: true,
},
workspaceDir: repoRoot,
moduleUrl: pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href,
}),
{ logger: NOOP_LOGGER },
);
await runtime.ensureSession({
sessionKey: "agent:codex:acp:plugin-tools-bridge",
agent: "codex",
mode: "persistent",
});
const logs = await readMockRuntimeLogEntries(fixture.logPath);
const ensureArgs = (logs.find((entry) => entry.kind === "ensure")?.args as string[]) ?? [];
const agentFlagIndex = ensureArgs.indexOf("--agent");
expect(agentFlagIndex).toBeGreaterThanOrEqual(0);
const rawAgentCommand = ensureArgs[agentFlagIndex + 1];
expect(rawAgentCommand).toContain("mcp-proxy.mjs");
const payloadMatch = rawAgentCommand.match(/--payload\s+([A-Za-z0-9_-]+)/);
expect(payloadMatch?.[1]).toBeDefined();
const payload = JSON.parse(
Buffer.from(String(payloadMatch?.[1]), "base64url").toString("utf8"),
) as {
targetCommand: string;
mcpServers: Array<{ name: string; command: string; args: string[] }>;
};
expect(payload.targetCommand).toContain("custom-codex-acp");
expect(payload.mcpServers).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "openclaw-plugin-tools",
command: process.execPath,
args: [distEntry],
}),
]),
);
} finally {
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("does not pass unknown agent ids through acpx --agent when MCP servers are configured", async () => {
const { runtime, logPath } = await createMockRuntimeFixture({
mcpServers: {

View File

@@ -357,6 +357,7 @@ export async function createMockRuntimeFixture(params?: {
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
nonInteractivePermissions: "fail",
pluginToolsMcpBridge: false,
strictWindowsCmdWrapper: true,
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
mcpServers: params?.mcpServers ?? {},

View File

@@ -6,37 +6,57 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
const isGlobal = process.env.npm_config_global === "true";
if (!isGlobal) {
process.exit(0);
}
export const BUNDLED_PLUGIN_INSTALL_TARGETS = [
{
pluginId: "acpx",
sentinelPath: join("node_modules", "acpx", "package.json"),
},
];
const __dirname = dirname(fileURLToPath(import.meta.url));
const extensionsDir = join(__dirname, "..", "dist", "extensions");
const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions");
// Extensions whose runtime deps include platform-specific binaries and therefore
// cannot be pre-bundled. Add entries here if new extensions share this pattern.
const NEEDS_INSTALL = ["acpx"];
export function createNestedNpmInstallEnv(env = process.env) {
const nextEnv = { ...env };
delete nextEnv.npm_config_global;
delete nextEnv.npm_config_prefix;
return nextEnv;
}
for (const ext of NEEDS_INSTALL) {
const extDir = join(extensionsDir, ext);
if (!existsSync(join(extDir, "package.json"))) {
continue;
export function runBundledPluginPostinstall(params = {}) {
const env = params.env ?? process.env;
if (env.npm_config_global !== "true") {
return;
}
// Skip if already installed (node_modules/.bin present).
if (existsSync(join(extDir, "node_modules", ".bin"))) {
continue;
}
try {
execSync("npm install --omit=dev --no-save --package-lock=false", {
cwd: extDir,
stdio: "pipe",
});
console.log(`[postinstall] installed bundled plugin deps: ${ext}`);
} catch (e) {
// Non-fatal: gateway will surface the missing dep via doctor.
console.warn(`[postinstall] could not install deps for ${ext}: ${String(e)}`);
const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR;
const exec = params.execSync ?? execSync;
const pathExists = params.existsSync ?? existsSync;
const log = params.log ?? console;
for (const target of BUNDLED_PLUGIN_INSTALL_TARGETS) {
const extDir = join(extensionsDir, target.pluginId);
if (!pathExists(join(extDir, "package.json"))) {
continue;
}
if (pathExists(join(extDir, target.sentinelPath))) {
continue;
}
try {
exec("npm install --omit=dev --no-save --package-lock=false", {
cwd: extDir,
env: createNestedNpmInstallEnv(env),
stdio: "pipe",
});
log.log(`[postinstall] installed bundled plugin deps: ${target.pluginId}`);
} catch (e) {
// Non-fatal: gateway will surface the missing dep via doctor.
log.warn(`[postinstall] could not install deps for ${target.pluginId}: ${String(e)}`);
}
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
runBundledPluginPostinstall();
}

View File

@@ -15,7 +15,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
*
* Each entry: { src: repo-root-relative source, dest: dist-relative dest }
*/
const STATIC_EXTENSION_ASSETS = [
export const STATIC_EXTENSION_ASSETS = [
// acpx MCP proxy — co-deployed alongside the acpx index bundle so that
// `path.resolve(dirname(import.meta.url), "mcp-proxy.mjs")` resolves correctly
// at runtime (see extensions/acpx/src/runtime-internals/mcp-agent-command.ts).
@@ -25,15 +25,19 @@ const STATIC_EXTENSION_ASSETS = [
},
];
function copyStaticExtensionAssets() {
for (const { src, dest } of STATIC_EXTENSION_ASSETS) {
const srcPath = path.join(ROOT, src);
const destPath = path.join(ROOT, dest);
if (fs.existsSync(srcPath)) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath);
export function copyStaticExtensionAssets(params = {}) {
const rootDir = params.rootDir ?? ROOT;
const assets = params.assets ?? STATIC_EXTENSION_ASSETS;
const fsImpl = params.fs ?? fs;
const warn = params.warn ?? console.warn;
for (const { src, dest } of assets) {
const srcPath = path.join(rootDir, src);
const destPath = path.join(rootDir, dest);
if (fsImpl.existsSync(srcPath)) {
fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
fsImpl.copyFileSync(srcPath, destPath);
} else {
console.warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
}
}
}

View File

@@ -0,0 +1,112 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { createPluginToolsMcpServer } from "./plugin-tools-serve.js";
async function connectPluginToolsServer(tools: AnyAgentTool[]) {
const server = createPluginToolsMcpServer({ tools });
const client = new Client({ name: "plugin-tools-test-client", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return {
client,
close: async () => {
await client.close();
await server.close();
},
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("plugin tools MCP server", () => {
it("lists registered plugin tools with their input schema", async () => {
const tool = {
name: "memory_recall",
description: "Recall stored memory",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
required: ["query"],
},
execute: vi.fn(),
} as unknown as AnyAgentTool;
const session = await connectPluginToolsServer([tool]);
try {
const listed = await session.client.listTools();
expect(listed.tools).toEqual([
expect.objectContaining({
name: "memory_recall",
description: "Recall stored memory",
inputSchema: expect.objectContaining({
type: "object",
required: ["query"],
}),
}),
]);
} finally {
await session.close();
}
});
it("serializes non-array tool content as text for MCP callers", async () => {
const execute = vi.fn().mockResolvedValue({
content: "Stored.",
});
const tool = {
name: "memory_store",
description: "Store memory",
parameters: { type: "object", properties: {} },
execute,
} as unknown as AnyAgentTool;
const session = await connectPluginToolsServer([tool]);
try {
const result = await session.client.callTool({
name: "memory_store",
arguments: { text: "remember this" },
});
expect(execute).toHaveBeenCalledWith(expect.stringMatching(/^mcp-\d+$/), {
text: "remember this",
});
expect(result.content).toEqual([{ type: "text", text: "Stored." }]);
} finally {
await session.close();
}
});
it("returns MCP errors for unknown tools and thrown tool errors", async () => {
const failingTool = {
name: "memory_forget",
description: "Forget memory",
parameters: { type: "object", properties: {} },
execute: vi.fn().mockRejectedValue(new Error("boom")),
} as unknown as AnyAgentTool;
const session = await connectPluginToolsServer([failingTool]);
try {
const unknown = await session.client.callTool({
name: "missing_tool",
arguments: {},
});
expect(unknown.isError).toBe(true);
expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]);
const failed = await session.client.callTool({
name: "memory_forget",
arguments: {},
});
expect(failed.isError).toBe(true);
expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]);
} finally {
await session.close();
}
});
});

View File

@@ -6,11 +6,14 @@
* Run via: node --import tsx src/mcp/plugin-tools-serve.ts
* Or: bun src/mcp/plugin-tools-serve.ts
*/
import { pathToFileURL } from "node:url";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { routeLogsToStderr } from "../logging/console.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { VERSION } from "../version.js";
@@ -23,16 +26,21 @@ function resolveJsonSchemaForTool(tool: AnyAgentTool): Record<string, unknown> {
return { type: "object", properties: {} };
}
async function main(): Promise<void> {
const cfg = loadConfig();
const tools = resolvePluginTools({
context: { config: cfg },
function resolveTools(config: OpenClawConfig): AnyAgentTool[] {
return resolvePluginTools({
context: { config },
suppressNameConflicts: true,
});
}
if (tools.length === 0) {
process.stderr.write("plugin-tools-serve: no plugin tools found\n");
}
export function createPluginToolsMcpServer(
params: {
config?: OpenClawConfig;
tools?: AnyAgentTool[];
} = {},
): Server {
const cfg = params.config ?? loadConfig();
const tools = params.tools ?? resolveTools(cfg);
const toolMap = new Map<string, AnyAgentTool>();
for (const tool of tools) {
@@ -77,6 +85,20 @@ async function main(): Promise<void> {
}
});
return server;
}
export async function servePluginToolsMcp(): Promise<void> {
// MCP stdio requires stdout to stay protocol-only.
routeLogsToStderr();
const config = loadConfig();
const tools = resolveTools(config);
const server = createPluginToolsMcpServer({ config, tools });
if (tools.length === 0) {
process.stderr.write("plugin-tools-serve: no plugin tools found\n");
}
const transport = new StdioServerTransport();
let shuttingDown = false;
@@ -100,7 +122,11 @@ async function main(): Promise<void> {
await server.connect(transport);
}
main().catch((err) => {
process.stderr.write(`plugin-tools-serve: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
servePluginToolsMcp().catch((err) => {
process.stderr.write(
`plugin-tools-serve: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});
}

View File

@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createNestedNpmInstallEnv,
runBundledPluginPostinstall,
} from "../../scripts/postinstall-bundled-plugins.mjs";
const cleanupDirs: string[] = [];
afterEach(async () => {
await Promise.all(
cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
async function createExtensionsDir() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-postinstall-"));
cleanupDirs.push(root);
const extensionsDir = path.join(root, "dist", "extensions");
await fs.mkdir(path.join(extensionsDir, "acpx"), { recursive: true });
await fs.writeFile(path.join(extensionsDir, "acpx", "package.json"), "{}\n", "utf8");
return extensionsDir;
}
describe("bundled plugin postinstall", () => {
it("clears global npm config before nested installs", () => {
expect(
createNestedNpmInstallEnv({
npm_config_global: "true",
npm_config_prefix: "/opt/homebrew",
HOME: "/tmp/home",
}),
).toEqual({
HOME: "/tmp/home",
});
});
it("installs bundled plugin deps only during global installs", async () => {
const extensionsDir = await createExtensionsDir();
const execSync = vi.fn();
runBundledPluginPostinstall({
env: { npm_config_global: "false" },
extensionsDir,
execSync,
});
expect(execSync).not.toHaveBeenCalled();
});
it("runs nested local installs with sanitized env when the sentinel package is missing", async () => {
const extensionsDir = await createExtensionsDir();
const execSync = vi.fn();
runBundledPluginPostinstall({
env: {
npm_config_global: "true",
npm_config_prefix: "/opt/homebrew",
HOME: "/tmp/home",
},
extensionsDir,
execSync,
log: { log: vi.fn(), warn: vi.fn() },
});
expect(execSync).toHaveBeenCalledWith("npm install --omit=dev --no-save --package-lock=false", {
cwd: path.join(extensionsDir, "acpx"),
env: {
HOME: "/tmp/home",
},
stdio: "pipe",
});
});
it("skips reinstall when the bundled sentinel package already exists", async () => {
const extensionsDir = await createExtensionsDir();
await fs.mkdir(path.join(extensionsDir, "acpx", "node_modules", "acpx"), { recursive: true });
await fs.writeFile(
path.join(extensionsDir, "acpx", "node_modules", "acpx", "package.json"),
"{}\n",
"utf8",
);
const execSync = vi.fn();
runBundledPluginPostinstall({
env: { npm_config_global: "true" },
extensionsDir,
execSync,
});
expect(execSync).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyStaticExtensionAssets } from "../../scripts/runtime-postbuild.mjs";
const cleanupDirs: string[] = [];
afterEach(async () => {
await Promise.all(
cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
async function createTempRoot() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-runtime-postbuild-"));
cleanupDirs.push(dir);
return dir;
}
describe("runtime postbuild static assets", () => {
it("copies declared static assets into dist", async () => {
const rootDir = await createTempRoot();
const src = "extensions/acpx/src/runtime-internals/mcp-proxy.mjs";
const dest = "dist/extensions/acpx/mcp-proxy.mjs";
const sourcePath = path.join(rootDir, src);
const destPath = path.join(rootDir, dest);
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, "proxy-data\n", "utf8");
copyStaticExtensionAssets({
rootDir,
assets: [{ src, dest }],
});
expect(await fs.readFile(destPath, "utf8")).toBe("proxy-data\n");
});
it("warns when a declared static asset is missing", async () => {
const rootDir = await createTempRoot();
const warn = vi.fn();
copyStaticExtensionAssets({
rootDir,
assets: [{ src: "missing/file.mjs", dest: "dist/file.mjs" }],
warn,
});
expect(warn).toHaveBeenCalledWith(
"[runtime-postbuild] static asset not found, skipping: missing/file.mjs",
);
});
});