fix(cli-runner): handle variadic Claude MCP configs

Fixes #98944
This commit is contained in:
Ayaan Zaidi
2026-07-01 23:29:34 -07:00
parent f8e1e0c5bf
commit cc5804868f
3 changed files with 159 additions and 13 deletions
+28 -7
View File
@@ -5,21 +5,40 @@ import fs from "node:fs/promises";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
/** Find an existing Claude `--mcp-config` argument value. */
export function findClaudeMcpConfigPath(args?: string[]): string | undefined {
/** Find existing Claude `--mcp-config` argument values. */
export function findClaudeMcpConfigPaths(args?: string[]): string[] {
const paths: string[] = [];
if (!args?.length) {
return undefined;
return paths;
}
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
if (arg === "--mcp-config") {
return normalizeOptionalString(args[i + 1]);
// Claude treats --mcp-config as variadic. Keep this scan aligned with
// extensions/anthropic/cli-shared.ts so user config files are not leaked
// as positional prompts after OpenClaw injects its strict overlay.
while (typeof args[i + 1] === "string" && !args[i + 1]?.startsWith("-")) {
i += 1;
const path = normalizeOptionalString(args[i]);
if (path) {
paths.push(path);
}
}
continue;
}
if (arg.startsWith("--mcp-config=")) {
return normalizeOptionalString(arg.slice("--mcp-config=".length));
const path = normalizeOptionalString(arg.slice("--mcp-config=".length));
if (path) {
paths.push(path);
}
}
}
return undefined;
return paths;
}
/** Find an existing Claude `--mcp-config` argument value. */
export function findClaudeMcpConfigPath(args?: string[]): string | undefined {
return findClaudeMcpConfigPaths(args)[0];
}
/** Return Claude args with OpenClaw's strict MCP config path injected. */
@@ -34,7 +53,9 @@ export function injectClaudeMcpConfigArgs(
continue;
}
if (arg === "--mcp-config") {
i += 1;
while (typeof args?.[i + 1] === "string" && !args[i + 1]?.startsWith("-")) {
i += 1;
}
continue;
}
if (arg.startsWith("--mcp-config=")) {
+121
View File
@@ -58,6 +58,127 @@ describe("prepareCliBundleMcpConfig", () => {
await prepared.cleanup?.();
});
it("strips variadic Claude --mcp-config values and merges every listed config", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-variadic-",
);
const firstConfig = path.join(workspaceDir, "first-mcp.json");
const secondConfig = path.join(workspaceDir, "second-mcp.json");
await fs.writeFile(
firstConfig,
`${JSON.stringify({
mcpServers: {
first: { command: "node", args: ["first.mjs"] },
shared: { command: "node", args: ["old.mjs"] },
},
})}\n`,
"utf-8",
);
await fs.writeFile(
secondConfig,
`${JSON.stringify({
mcpServers: {
second: { command: "node", args: ["second.mjs"] },
shared: { command: "node", args: ["new.mjs"] },
},
})}\n`,
"utf-8",
);
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: [
"./fake-claude.mjs",
"--mcp-config",
"first-mcp.json",
"second-mcp.json",
"--verbose",
],
},
workspaceDir,
config: { plugins: { enabled: false } },
});
expect(prepared.backend.args).not.toContain("first-mcp.json");
expect(prepared.backend.args).not.toContain("second-mcp.json");
expect(prepared.backend.args).toContain("--verbose");
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, { args?: string[] }>;
};
expect(raw.mcpServers?.first?.args).toEqual(["first.mjs"]);
expect(raw.mcpServers?.second?.args).toEqual(["second.mjs"]);
expect(raw.mcpServers?.shared?.args).toEqual(["new.mjs"]);
await prepared.cleanup?.();
});
it("merges and strips Claude --mcp-config equals form", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-equals-",
);
const configPath = path.join(workspaceDir, "equals-mcp.json");
await fs.writeFile(
configPath,
`${JSON.stringify({
mcpServers: {
equals: { command: "node", args: ["equals.mjs"] },
},
})}\n`,
"utf-8",
);
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs", "--mcp-config=equals-mcp.json"],
},
workspaceDir,
config: { plugins: { enabled: false } },
});
expect(prepared.backend.args).not.toContain("--mcp-config=equals-mcp.json");
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, { args?: string[] }>;
};
expect(raw.mcpServers?.equals?.args).toEqual(["equals.mjs"]);
await prepared.cleanup?.();
});
it("keeps dash-prefixed args after Claude --mcp-config because they terminate variadic values", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-dash-",
);
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs", "--mcp-config", "--verbose", "prompt"],
},
workspaceDir,
config: { plugins: { enabled: false } },
});
expect(prepared.backend.args).toContain("--verbose");
expect(prepared.backend.args).toContain("prompt");
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
expect(raw.mcpServers).toStrictEqual({});
await prepared.cleanup?.();
});
it("loads workspace bundle MCP plugins from the configured workspace root", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-workspace-root-",
+10 -6
View File
@@ -15,6 +15,7 @@ import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle
import { isRecord } from "./bundle-mcp-adapter-shared.js";
import {
findClaudeMcpConfigPath,
findClaudeMcpConfigPaths,
injectClaudeMcpConfigArgs,
writeClaudeMcpCaptureConfig,
} from "./bundle-mcp-claude.js";
@@ -188,14 +189,17 @@ export async function prepareCliBundleMcpConfig(params: {
}
const mode = resolveBundleMcpMode(params.mode);
const existingMcpConfigPath =
mode === "claude-config-file"
? (findClaudeMcpConfigPath(params.backend.resumeArgs) ??
findClaudeMcpConfigPath(params.backend.args))
: undefined;
const resumeMcpConfigPaths =
mode === "claude-config-file" ? findClaudeMcpConfigPaths(params.backend.resumeArgs) : [];
const existingMcpConfigPaths =
mode === "claude-config-file" && resumeMcpConfigPaths.length > 0
? resumeMcpConfigPaths
: mode === "claude-config-file"
? findClaudeMcpConfigPaths(params.backend.args)
: [];
let mergedConfig: BundleMcpConfig = { mcpServers: {} };
if (existingMcpConfigPath) {
for (const existingMcpConfigPath of existingMcpConfigPaths) {
// Merge any user-provided Claude MCP config first so bundle/plugin config can
// override intentionally managed server entries.
const resolvedExistingPath = path.isAbsolute(existingMcpConfigPath)