feat(plugins): test bundle MCP end to end

This commit is contained in:
Peter Steinberger 2026-03-15 16:51:04 -07:00
parent c3ed3ba310
commit a058bf918d
No known key found for this signature in database
9 changed files with 968 additions and 59 deletions

View File

@ -219,6 +219,9 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de
}
console.log("ok");
NODE
echo "Running bundle MCP CLI-agent e2e..."
pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts
'
echo "OK"

View File

@ -0,0 +1,205 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { runCliAgent } from "./cli-runner.js";
const E2E_TIMEOUT_MS = 20_000;
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const SDK_CLIENT_INDEX_PATH = require.resolve("@modelcontextprotocol/sdk/client/index.js");
const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/stdio.js");
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeFakeClaudeCli(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import fs from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { Client } from ${JSON.stringify(SDK_CLIENT_INDEX_PATH)};
import { StdioClientTransport } from ${JSON.stringify(SDK_CLIENT_STDIO_PATH)};
function readArg(name) {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
if (arg === name) {
return args[i + 1];
}
if (arg.startsWith(name + "=")) {
return arg.slice(name.length + 1);
}
}
return undefined;
}
const mcpConfigPath = readArg("--mcp-config");
if (!mcpConfigPath) {
throw new Error("missing --mcp-config");
}
const raw = JSON.parse(await fs.readFile(mcpConfigPath, "utf-8"));
const servers = raw?.mcpServers ?? raw?.servers ?? {};
const server = servers.bundleProbe ?? Object.values(servers)[0];
if (!server || typeof server !== "object") {
throw new Error("missing bundleProbe MCP server");
}
const transport = new StdioClientTransport({
command: server.command,
args: Array.isArray(server.args) ? server.args : [],
env: server.env && typeof server.env === "object" ? server.env : undefined,
cwd:
typeof server.cwd === "string"
? server.cwd
: typeof server.workingDirectory === "string"
? server.workingDirectory
: undefined,
});
const client = new Client({ name: "fake-claude", version: "1.0.0" });
await client.connect(transport);
const tools = await client.listTools();
if (!tools.tools.some((tool) => tool.name === "bundle_probe")) {
throw new Error("bundle_probe tool not exposed");
}
const result = await client.callTool({ name: "bundle_probe", arguments: {} });
await transport.close();
const text = Array.isArray(result.content)
? result.content
.filter((entry) => entry?.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text)
.join("\\n")
: "";
process.stdout.write(
JSON.stringify({
session_id: readArg("--session-id") ?? randomUUID(),
message: "BUNDLE MCP OK " + text,
}) + "\\n",
);
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
describe("runCliAgent bundle MCP e2e", () => {
it(
"routes enabled bundle MCP config into the claude-cli backend and executes the tool",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const envSnapshot = captureEnv(["HOME"]);
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-bundle-mcp-"));
process.env.HOME = tempHome;
const workspaceDir = path.join(tempHome, "workspace");
const sessionFile = path.join(tempHome, "session.jsonl");
const binDir = path.join(tempHome, "bin");
const serverScriptPath = path.join(tempHome, "mcp", "bundle-probe.mjs");
const fakeClaudePath = path.join(binDir, "fake-claude.mjs");
const pluginRoot = path.join(tempHome, ".openclaw", "extensions", "bundle-probe");
await fs.mkdir(workspaceDir, { recursive: true });
await writeBundleProbeMcpServer(serverScriptPath);
await writeFakeClaudeCli(fakeClaudePath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const config: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
cliBackends: {
"claude-cli": {
command: "node",
args: [fakeClaudePath],
clearEnv: [],
},
},
},
},
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
try {
const result = await runCliAgent({
sessionId: "session:test",
sessionFile,
workspaceDir,
config,
prompt: "Use your configured MCP tools and report the bundle probe text.",
provider: "claude-cli",
model: "test-bundle",
timeoutMs: 10_000,
runId: "bundle-mcp-e2e",
});
expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE");
expect(result.meta.agentMeta?.sessionId.length ?? 0).toBeGreaterThan(0);
} finally {
await fs.rm(tempHome, { recursive: true, force: true });
envSnapshot.restore();
}
},
);
});

View File

@ -18,6 +18,7 @@ import {
} from "./bootstrap-budget.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import { prepareCliBundleMcpConfig } from "./cli-runner/bundle-mcp.js";
import {
appendImagePathsToPrompt,
buildCliSupervisorScopeKey,
@ -92,7 +93,14 @@ export async function runCliAgent(params: {
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
const backend = backendResolved.config;
const preparedBackend = await prepareCliBundleMcpConfig({
backendId: backendResolved.id,
backend: backendResolved.config,
workspaceDir,
config: params.config,
warn: (message) => log.warn(message),
});
const backend = preparedBackend.backend;
const modelId = (params.model ?? "default").trim() || "default";
const normalizedModel = normalizeCliModel(modelId, backend);
const modelDisplay = `${params.provider}/${modelId}`;
@ -406,68 +414,72 @@ export async function runCliAgent(params: {
// Try with the provided CLI session ID first
try {
const output = await executeCliWithSession(params.cliSessionId);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
try {
const output = await executeCliWithSession(params.cliSessionId);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "",
return {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "",
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
} catch (err) {
if (err instanceof FailoverError) {
// Check if this is a session expired error and we have a session to clear
if (err.reason === "session_expired" && params.cliSessionId && params.sessionKey) {
log.warn(
`CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(params.cliSessionId)}`,
);
// Clear the expired session ID from the session entry
// This requires access to the session store, which we don't have here
// We'll need to modify the caller to handle this case
// For now, retry without the session ID to create a new session
const output = await executeCliWithSession(undefined);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.sessionId ?? "",
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
}
throw err;
}
const message = err instanceof Error ? err.message : String(err);
if (isFailoverErrorMessage(message)) {
const reason = classifyFailoverReason(message) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(message, {
reason,
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
} catch (err) {
if (err instanceof FailoverError) {
// Check if this is a session expired error and we have a session to clear
if (err.reason === "session_expired" && params.cliSessionId && params.sessionKey) {
log.warn(
`CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(params.cliSessionId)}`,
);
// Clear the expired session ID from the session entry
// This requires access to the session store, which we don't have here
// We'll need to modify the caller to handle this case
// For now, retry without the session ID to create a new session
const output = await executeCliWithSession(undefined);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.sessionId ?? "",
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
status,
});
}
throw err;
}
const message = err instanceof Error ? err.message : String(err);
if (isFailoverErrorMessage(message)) {
const reason = classifyFailoverReason(message) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(message, {
reason,
provider: params.provider,
model: modelId,
status,
});
}
throw err;
} finally {
await preparedBackend.cleanup?.();
}
}

View File

@ -0,0 +1,93 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js";
import { captureEnv } from "../../test-utils/env.js";
import { prepareCliBundleMcpConfig } from "./bundle-mcp.js";
const tempDirs: string[] = [];
async function createTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
clearPluginManifestRegistryCache();
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("prepareCliBundleMcpConfig", () => {
it("injects a merged --mcp-config overlay for claude-cli", async () => {
const env = captureEnv(["HOME"]);
try {
const homeDir = await createTempDir("openclaw-cli-bundle-mcp-home-");
const workspaceDir = await createTempDir("openclaw-cli-bundle-mcp-workspace-");
process.env.HOME = homeDir;
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe");
const serverPath = path.join(pluginRoot, "servers", "probe.mjs");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.dirname(serverPath), { recursive: true });
await fs.writeFile(serverPath, "export {};\n", "utf-8");
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: ["./servers/probe.mjs"],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const config: OpenClawConfig = {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
const prepared = await prepareCliBundleMcpConfig({
backendId: "claude-cli",
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config,
});
const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1;
expect(configFlagIndex).toBeGreaterThanOrEqual(0);
expect(prepared.backend.args).toContain("--strict-mcp-config");
const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1];
expect(typeof generatedConfigPath).toBe("string");
const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as {
mcpServers?: Record<string, { args?: string[] }>;
};
expect(raw.mcpServers?.bundleProbe?.args).toEqual([await fs.realpath(serverPath)]);
await prepared.cleanup?.();
} finally {
env.restore();
}
});
});

View File

@ -0,0 +1,143 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../../config/config.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import type { CliBackendConfig } from "../../config/types.js";
import {
loadEnabledBundleMcpConfig,
type BundleMcpConfig,
type BundleMcpServerConfig,
} from "../../plugins/bundle-mcp.js";
import { isRecord } from "../../utils.js";
type PreparedCliBundleMcpConfig = {
backend: CliBackendConfig;
cleanup?: () => Promise<void>;
};
function extractServerMap(raw: unknown): Record<string, BundleMcpServerConfig> {
if (!isRecord(raw)) {
return {};
}
const nested = isRecord(raw.mcpServers)
? raw.mcpServers
: isRecord(raw.servers)
? raw.servers
: raw;
if (!isRecord(nested)) {
return {};
}
const result: Record<string, BundleMcpServerConfig> = {};
for (const [serverName, serverRaw] of Object.entries(nested)) {
if (!isRecord(serverRaw)) {
continue;
}
result[serverName] = { ...serverRaw };
}
return result;
}
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
try {
const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown;
return { mcpServers: extractServerMap(raw) };
} catch {
return { mcpServers: {} };
}
}
function findMcpConfigPath(args?: string[]): string | undefined {
if (!args?.length) {
return undefined;
}
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
if (arg === "--mcp-config") {
const next = args[i + 1];
return typeof next === "string" && next.trim() ? next.trim() : undefined;
}
if (arg.startsWith("--mcp-config=")) {
const inline = arg.slice("--mcp-config=".length).trim();
return inline || undefined;
}
}
return undefined;
}
function injectMcpConfigArgs(args: string[] | undefined, mcpConfigPath: string): string[] {
const next: string[] = [];
for (let i = 0; i < (args?.length ?? 0); i += 1) {
const arg = args?.[i] ?? "";
if (arg === "--strict-mcp-config") {
continue;
}
if (arg === "--mcp-config") {
i += 1;
continue;
}
if (arg.startsWith("--mcp-config=")) {
continue;
}
next.push(arg);
}
next.push("--strict-mcp-config", "--mcp-config", mcpConfigPath);
return next;
}
export async function prepareCliBundleMcpConfig(params: {
backendId: string;
backend: CliBackendConfig;
workspaceDir: string;
config?: OpenClawConfig;
warn?: (message: string) => void;
}): Promise<PreparedCliBundleMcpConfig> {
if (params.backendId !== "claude-cli") {
return { backend: params.backend };
}
const existingMcpConfigPath =
findMcpConfigPath(params.backend.resumeArgs) ?? findMcpConfigPath(params.backend.args);
let mergedConfig: BundleMcpConfig = { mcpServers: {} };
if (existingMcpConfigPath) {
const resolvedExistingPath = path.isAbsolute(existingMcpConfigPath)
? existingMcpConfigPath
: path.resolve(params.workspaceDir, existingMcpConfigPath);
mergedConfig = applyMergePatch(
mergedConfig,
await readExternalMcpConfig(resolvedExistingPath),
) as BundleMcpConfig;
}
const bundleConfig = loadEnabledBundleMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.config,
});
for (const diagnostic of bundleConfig.diagnostics) {
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig;
if (Object.keys(mergedConfig.mcpServers).length === 0) {
return { backend: params.backend };
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-"));
const mcpConfigPath = path.join(tempDir, "mcp.json");
await fs.writeFile(mcpConfigPath, `${JSON.stringify(mergedConfig, null, 2)}\n`, "utf-8");
return {
backend: {
...params.backend,
args: injectMcpConfigArgs(params.backend.args, mcpConfigPath),
resumeArgs: injectMcpConfigArgs(
params.backend.resumeArgs ?? params.backend.args ?? [],
mcpConfigPath,
),
},
cleanup: async () => {
await fs.rm(tempDir, { recursive: true, force: true });
},
};
}

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@ -33,7 +34,9 @@ const hookMocks = vi.hoisted(() => ({
hasHooks: vi.fn(() => false),
runInboundClaim: vi.fn(async () => undefined),
runInboundClaimForPlugin: vi.fn(async () => undefined),
runInboundClaimForPluginOutcome: vi.fn(async () => ({ status: "no_handler" as const })),
runInboundClaimForPluginOutcome: vi.fn<() => Promise<PluginTargetedInboundClaimOutcome>>(
async () => ({ status: "no_handler" as const }),
),
runMessageReceived: vi.fn(async () => {}),
},
}));

View File

@ -0,0 +1,148 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
const tempDirs: string[] = [];
async function createTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
clearPluginManifestRegistryCache();
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("loadEnabledBundleMcpConfig", () => {
it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => {
const env = captureEnv(["HOME"]);
try {
const homeDir = await createTempDir("openclaw-bundle-mcp-home-");
const workspaceDir = await createTempDir("openclaw-bundle-mcp-workspace-");
process.env.HOME = homeDir;
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe");
const serverPath = path.join(pluginRoot, "servers", "probe.mjs");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.dirname(serverPath), { recursive: true });
await fs.writeFile(serverPath, "export {};\n", "utf-8");
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: ["./servers/probe.mjs"],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const config: OpenClawConfig = {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: config,
});
const resolvedServerPath = await fs.realpath(serverPath);
expect(loaded.diagnostics).toEqual([]);
expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node");
expect(loaded.config.mcpServers.bundleProbe?.args).toEqual([resolvedServerPath]);
} finally {
env.restore();
}
});
it("merges inline bundle MCP servers and skips disabled bundles", async () => {
const env = captureEnv(["HOME"]);
try {
const homeDir = await createTempDir("openclaw-bundle-inline-home-");
const workspaceDir = await createTempDir("openclaw-bundle-inline-workspace-");
process.env.HOME = homeDir;
const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled");
const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled");
await fs.mkdir(path.join(enabledRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(disabledRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(enabledRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-enabled",
mcpServers: {
enabledProbe: {
command: "node",
args: ["./enabled.mjs"],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(disabledRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-disabled",
mcpServers: {
disabledProbe: {
command: "node",
args: ["./disabled.mjs"],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const config: OpenClawConfig = {
plugins: {
entries: {
"inline-enabled": { enabled: true },
"inline-disabled": { enabled: false },
},
},
};
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: config,
});
expect(loaded.config.mcpServers.enabledProbe).toBeDefined();
expect(loaded.config.mcpServers.disabledProbe).toBeUndefined();
} finally {
env.restore();
}
});
});

300
src/plugins/bundle-mcp.ts Normal file
View File

@ -0,0 +1,300 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isRecord } from "../utils.js";
import {
CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
} from "./bundle-manifest.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginBundleFormat } from "./types.js";
export type BundleMcpServerConfig = Record<string, unknown>;
export type BundleMcpConfig = {
mcpServers: Record<string, BundleMcpServerConfig>;
};
export type BundleMcpDiagnostic = {
pluginId: string;
message: string;
};
export type EnabledBundleMcpConfigResult = {
config: BundleMcpConfig;
diagnostics: BundleMcpDiagnostic[];
};
const MANIFEST_PATH_BY_FORMAT: Record<PluginBundleFormat, string> = {
claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
codex: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
cursor: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
};
function normalizePathList(value: unknown): string[] {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function mergeUniquePathLists(...groups: string[][]): string[] {
const merged: string[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const entry of group) {
if (seen.has(entry)) {
continue;
}
seen.add(entry);
merged.push(entry);
}
}
return merged;
}
function readPluginJsonObject(params: {
rootDir: string;
relativePath: string;
allowMissing?: boolean;
}): { ok: true; raw: Record<string, unknown> } | { ok: false; error: string } {
const absolutePath = path.join(params.rootDir, params.relativePath);
const opened = openBoundaryFileSync({
absolutePath,
rootPath: params.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: true,
});
if (!opened.ok) {
if (opened.reason === "path" && params.allowMissing) {
return { ok: true, raw: {} };
}
return { ok: false, error: `unable to read ${params.relativePath}: ${opened.reason}` };
}
try {
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
if (!isRecord(raw)) {
return { ok: false, error: `${params.relativePath} must contain a JSON object` };
}
return { ok: true, raw };
} catch (error) {
return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` };
} finally {
fs.closeSync(opened.fd);
}
}
function resolveBundleMcpConfigPaths(params: {
raw: Record<string, unknown>;
rootDir: string;
bundleFormat: PluginBundleFormat;
}): string[] {
const declared = normalizePathList(params.raw.mcpServers);
const defaults = fs.existsSync(path.join(params.rootDir, ".mcp.json")) ? [".mcp.json"] : [];
if (params.bundleFormat === "claude") {
return mergeUniquePathLists(defaults, declared);
}
return mergeUniquePathLists(defaults, declared);
}
function extractMcpServerMap(raw: unknown): Record<string, BundleMcpServerConfig> {
if (!isRecord(raw)) {
return {};
}
const nested = isRecord(raw.mcpServers)
? raw.mcpServers
: isRecord(raw.servers)
? raw.servers
: raw;
if (!isRecord(nested)) {
return {};
}
const result: Record<string, BundleMcpServerConfig> = {};
for (const [serverName, serverRaw] of Object.entries(nested)) {
if (!isRecord(serverRaw)) {
continue;
}
result[serverName] = { ...serverRaw };
}
return result;
}
function isExplicitRelativePath(value: string): boolean {
return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../");
}
function absolutizeBundleMcpServer(params: {
baseDir: string;
server: BundleMcpServerConfig;
}): BundleMcpServerConfig {
const next: BundleMcpServerConfig = { ...params.server };
const command = next.command;
if (typeof command === "string" && isExplicitRelativePath(command)) {
next.command = path.resolve(params.baseDir, command);
}
const cwd = next.cwd;
if (typeof cwd === "string" && !path.isAbsolute(cwd)) {
next.cwd = path.resolve(params.baseDir, cwd);
}
const workingDirectory = next.workingDirectory;
if (typeof workingDirectory === "string" && !path.isAbsolute(workingDirectory)) {
next.workingDirectory = path.resolve(params.baseDir, workingDirectory);
}
if (Array.isArray(next.args)) {
next.args = next.args.map((entry) => {
if (typeof entry !== "string" || !isExplicitRelativePath(entry)) {
return entry;
}
return path.resolve(params.baseDir, entry);
});
}
return next;
}
function loadBundleFileBackedMcpConfig(params: {
rootDir: string;
relativePath: string;
}): BundleMcpConfig {
const absolutePath = path.resolve(params.rootDir, params.relativePath);
const opened = openBoundaryFileSync({
absolutePath,
rootPath: params.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: true,
});
if (!opened.ok) {
return { mcpServers: {} };
}
try {
const stat = fs.fstatSync(opened.fd);
if (!stat.isFile()) {
return { mcpServers: {} };
}
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
const servers = extractMcpServerMap(raw);
const baseDir = path.dirname(absolutePath);
return {
mcpServers: Object.fromEntries(
Object.entries(servers).map(([serverName, server]) => [
serverName,
absolutizeBundleMcpServer({ baseDir, server }),
]),
),
};
} finally {
fs.closeSync(opened.fd);
}
}
function loadBundleInlineMcpConfig(params: {
raw: Record<string, unknown>;
baseDir: string;
}): BundleMcpConfig {
if (!isRecord(params.raw.mcpServers)) {
return { mcpServers: {} };
}
const servers = extractMcpServerMap(params.raw.mcpServers);
return {
mcpServers: Object.fromEntries(
Object.entries(servers).map(([serverName, server]) => [
serverName,
absolutizeBundleMcpServer({ baseDir: params.baseDir, server }),
]),
),
};
}
function loadBundleMcpConfig(params: {
pluginId: string;
rootDir: string;
bundleFormat: PluginBundleFormat;
}): { config: BundleMcpConfig; diagnostics: string[] } {
const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat];
const manifestLoaded = readPluginJsonObject({
rootDir: params.rootDir,
relativePath: manifestRelativePath,
allowMissing: params.bundleFormat === "claude",
});
if (!manifestLoaded.ok) {
return { config: { mcpServers: {} }, diagnostics: [manifestLoaded.error] };
}
let merged: BundleMcpConfig = { mcpServers: {} };
const filePaths = resolveBundleMcpConfigPaths({
raw: manifestLoaded.raw,
rootDir: params.rootDir,
bundleFormat: params.bundleFormat,
});
for (const relativePath of filePaths) {
merged = applyMergePatch(
merged,
loadBundleFileBackedMcpConfig({
rootDir: params.rootDir,
relativePath,
}),
) as BundleMcpConfig;
}
merged = applyMergePatch(
merged,
loadBundleInlineMcpConfig({
raw: manifestLoaded.raw,
baseDir: path.dirname(path.join(params.rootDir, manifestRelativePath)),
}),
) as BundleMcpConfig;
return { config: merged, diagnostics: [] };
}
export function loadEnabledBundleMcpConfig(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
}): EnabledBundleMcpConfigResult {
const registry = loadPluginManifestRegistry({
workspaceDir: params.workspaceDir,
config: params.cfg,
});
const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins);
const diagnostics: BundleMcpDiagnostic[] = [];
let merged: BundleMcpConfig = { mcpServers: {} };
for (const record of registry.plugins) {
if (record.format !== "bundle" || !record.bundleFormat) {
continue;
}
const enableState = resolveEffectiveEnableState({
id: record.id,
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.cfg,
});
if (!enableState.enabled) {
continue;
}
const loaded = loadBundleMcpConfig({
pluginId: record.id,
rootDir: record.rootDir,
bundleFormat: record.bundleFormat,
});
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig;
for (const message of loaded.diagnostics) {
diagnostics.push({ pluginId: record.id, message });
}
}
return { config: merged, diagnostics };
}

View File

@ -326,8 +326,10 @@ describe("plugin conversation binding approvals", () => {
}
expect(approved.binding.detachHint).toBe("/codex_detach");
} else {
} else if (request.status === "bound") {
expect(request.binding.detachHint).toBe("/codex_detach");
} else {
throw new Error(`expected pending or bound request, got ${request.status}`);
}
const currentBinding = await getCurrentPluginConversationBinding({