mirror of https://github.com/openclaw/openclaw.git
feat(plugins): test bundle MCP end to end
This commit is contained in:
parent
c3ed3ba310
commit
a058bf918d
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 () => {}),
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue