diff --git a/src/agents/cli-runner/bundle-mcp-claude.ts b/src/agents/cli-runner/bundle-mcp-claude.ts index 1faa555d1f01..95a96d69c572 100644 --- a/src/agents/cli-runner/bundle-mcp-claude.ts +++ b/src/agents/cli-runner/bundle-mcp-claude.ts @@ -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=")) { diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 5bd36c68ed1d..004c8bed9e42 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -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; + }; + 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; + }; + 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; + }; + 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-", diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index a53b6a51329a..86a6188db601 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -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)