diff --git a/src/agents/cli-runner/bundle-mcp-adapter-shared.ts b/src/agents/cli-runner/bundle-mcp-adapter-shared.ts new file mode 100644 index 00000000000..de2714e9c38 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp-adapter-shared.ts @@ -0,0 +1,58 @@ +import type { BundleMcpServerConfig } from "../../plugins/bundle-mcp.js"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function normalizeStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? [...value] + : undefined; +} + +export function normalizeStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const entries = Object.entries(value).filter((entry): entry is [string, string] => { + return typeof entry[1] === "string"; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +export function decodeHeaderEnvPlaceholder( + value: string, +): { envVar: string; bearer: boolean } | null { + const bearerMatch = /^Bearer \${([A-Z0-9_]+)}$/.exec(value); + if (bearerMatch) { + return { envVar: bearerMatch[1], bearer: true }; + } + const envMatch = /^\${([A-Z0-9_]+)}$/.exec(value); + if (envMatch) { + return { envVar: envMatch[1], bearer: false }; + } + return null; +} + +export function applyCommonServerConfig( + next: Record, + server: BundleMcpServerConfig, +): void { + if (typeof server.command === "string") { + next.command = server.command; + } + const args = normalizeStringArray(server.args); + if (args) { + next.args = args; + } + const env = normalizeStringRecord(server.env); + if (env) { + next.env = env; + } + if (typeof server.cwd === "string") { + next.cwd = server.cwd; + } + if (typeof server.url === "string") { + next.url = server.url; + } +} diff --git a/src/agents/cli-runner/bundle-mcp-claude.ts b/src/agents/cli-runner/bundle-mcp-claude.ts new file mode 100644 index 00000000000..abd7461ce0c --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp-claude.ts @@ -0,0 +1,40 @@ +import { normalizeOptionalString } from "../../shared/string-coerce.js"; + +export function findClaudeMcpConfigPath(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") { + return normalizeOptionalString(args[i + 1]); + } + if (arg.startsWith("--mcp-config=")) { + return normalizeOptionalString(arg.slice("--mcp-config=".length)); + } + } + return undefined; +} + +export function injectClaudeMcpConfigArgs( + 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; +} diff --git a/src/agents/cli-runner/bundle-mcp-codex.ts b/src/agents/cli-runner/bundle-mcp-codex.ts new file mode 100644 index 00000000000..c6a338cb28a --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp-codex.ts @@ -0,0 +1,66 @@ +import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js"; +import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { + applyCommonServerConfig, + decodeHeaderEnvPlaceholder, + normalizeStringRecord, +} from "./bundle-mcp-adapter-shared.js"; +import { serializeTomlInlineValue } from "./toml-inline.js"; + +function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig): boolean { + return ( + name === "openclaw" && + typeof server.url === "string" && + /^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/mcp(?:[?#].*)?$/.test(server.url) + ); +} + +function normalizeCodexServerConfig( + name: string, + server: BundleMcpServerConfig, +): Record { + const next: Record = {}; + applyCommonServerConfig(next, server); + if (isOpenClawLoopbackMcpServer(name, server)) { + next.default_tools_approval_mode = "approve"; + } + const httpHeaders = normalizeStringRecord(server.headers); + if (httpHeaders) { + const staticHeaders: Record = {}; + const envHeaders: Record = {}; + for (const [name, value] of Object.entries(httpHeaders)) { + const decoded = decodeHeaderEnvPlaceholder(value); + if (!decoded) { + staticHeaders[name] = value; + continue; + } + if (decoded.bearer && normalizeOptionalLowercaseString(name) === "authorization") { + next.bearer_token_env_var = decoded.envVar; + continue; + } + envHeaders[name] = decoded.envVar; + } + if (Object.keys(staticHeaders).length > 0) { + next.http_headers = staticHeaders; + } + if (Object.keys(envHeaders).length > 0) { + next.env_http_headers = envHeaders; + } + } + return next; +} + +export function injectCodexMcpConfigArgs( + args: string[] | undefined, + config: BundleMcpConfig, +): string[] { + const overrides = serializeTomlInlineValue( + Object.fromEntries( + Object.entries(config.mcpServers).map(([name, server]) => [ + name, + normalizeCodexServerConfig(name, server), + ]), + ), + ); + return [...(args ?? []), "-c", `mcp_servers=${overrides}`]; +} diff --git a/src/agents/cli-runner/bundle-mcp-gemini.ts b/src/agents/cli-runner/bundle-mcp-gemini.ts new file mode 100644 index 00000000000..e274e817ea8 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp-gemini.ts @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { applyMergePatch } from "../../config/merge-patch.js"; +import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js"; +import { + applyCommonServerConfig, + decodeHeaderEnvPlaceholder, + isRecord, + normalizeStringRecord, +} from "./bundle-mcp-adapter-shared.js"; + +async function readJsonObject(filePath: string): Promise> { + try { + const raw = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown; + return raw && typeof raw === "object" && !Array.isArray(raw) + ? ({ ...raw } as Record) + : {}; + } catch { + return {}; + } +} + +function resolveEnvPlaceholder( + value: string, + inheritedEnv: Record | undefined, +): string { + const decoded = decodeHeaderEnvPlaceholder(value); + if (!decoded) { + return value; + } + const resolved = inheritedEnv?.[decoded.envVar] ?? process.env[decoded.envVar] ?? ""; + return decoded.bearer ? `Bearer ${resolved}` : resolved; +} + +function normalizeGeminiServerConfig( + server: BundleMcpServerConfig, + inheritedEnv: Record | undefined, +): Record { + const next: Record = {}; + applyCommonServerConfig(next, server); + if (typeof server.type === "string") { + next.type = server.type; + } + const headers = normalizeStringRecord(server.headers); + if (headers) { + next.headers = Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + resolveEnvPlaceholder(value, inheritedEnv), + ]), + ); + } + if (typeof server.trust === "boolean") { + next.trust = server.trust; + } + return next; +} + +export async function writeGeminiSystemSettings( + mergedConfig: BundleMcpConfig, + inheritedEnv: Record | undefined, +): Promise<{ env: Record; cleanup: () => Promise }> { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gemini-mcp-")); + const settingsPath = path.join(tempDir, "settings.json"); + const existingSettingsPath = + inheritedEnv?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH; + const base = + typeof existingSettingsPath === "string" && existingSettingsPath.trim() + ? await readJsonObject(existingSettingsPath) + : {}; + const normalizedConfig: BundleMcpConfig = { + mcpServers: Object.fromEntries( + Object.entries(mergedConfig.mcpServers).map(([name, server]) => [ + name, + normalizeGeminiServerConfig(server, inheritedEnv), + ]), + ) as BundleMcpConfig["mcpServers"], + }; + const settings = applyMergePatch(base, { + mcp: { + allowed: Object.keys(normalizedConfig.mcpServers), + }, + mcpServers: normalizedConfig.mcpServers, + }) as Record; + if (!isRecord(settings.mcp) || !isRecord(settings.mcpServers)) { + throw new Error("Gemini MCP settings merge produced an invalid object"); + } + await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); + return { + env: { + ...inheritedEnv, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath, + }, + cleanup: async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/agents/cli-runner/bundle-mcp.resume.test.ts b/src/agents/cli-runner/bundle-mcp.resume.test.ts new file mode 100644 index 00000000000..7f47937c0c5 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.resume.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + prepareBundleProbeCliConfig, + setupCliBundleMcpTestHarness, +} from "./bundle-mcp.test-support.js"; + +setupCliBundleMcpTestHarness(); + +describe("prepareCliBundleMcpConfig resume hash", () => { + it("stabilizes the resume hash when only the OpenClaw loopback port changes", async () => { + const first = await prepareBundleProbeCliConfig({ + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + }, + }, + }, + }, + }); + const second = await prepareBundleProbeCliConfig({ + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:24567/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + }, + }, + }, + }, + }); + + expect(first.mcpConfigHash).not.toBe(second.mcpConfigHash); + expect(first.mcpResumeHash).toBe(second.mcpResumeHash); + + await first.cleanup?.(); + await second.cleanup?.(); + }); + + it("changes the resume hash when stable MCP semantics change", async () => { + const first = await prepareBundleProbeCliConfig({ + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + }, + }, + }, + }, + }); + const second = await prepareBundleProbeCliConfig({ + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/other", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + }, + }, + }, + }, + }); + + expect(first.mcpResumeHash).not.toBe(second.mcpResumeHash); + + await first.cleanup?.(); + await second.cleanup?.(); + }); +}); diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 34b05db5c82..668ae77fe35 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js"; -import { captureEnv } from "../../test-utils/env.js"; import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; import { cliBundleMcpHarness, @@ -146,336 +145,6 @@ describe("prepareCliBundleMcpConfig", () => { await prepared.cleanup?.(); }); - it("merges user-configured mcp.servers from OpenClaw config", async () => { - const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( - "openclaw-cli-bundle-mcp-user-servers-", - ); - - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "claude-config-file", - backend: { - command: "node", - args: ["./fake-claude.mjs"], - }, - workspaceDir, - config: { - plugins: { enabled: false }, - mcp: { - servers: { - omi: { - type: "sse", - url: "https://api.omi.me/v1/mcp/sse", - headers: { Authorization: "Bearer test-token" }, - }, - }, - }, - }, - }); - - const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; - expect(configFlagIndex).toBeGreaterThanOrEqual(0); - const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; - const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { - mcpServers?: Record; - }; - expect(raw.mcpServers?.omi?.type).toBe("sse"); - expect(raw.mcpServers?.omi?.url).toBe("https://api.omi.me/v1/mcp/sse"); - - await prepared.cleanup?.(); - }); - - it("translates OpenClaw transport field on user mcp.servers into Claude type", async () => { - const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( - "openclaw-cli-bundle-mcp-user-servers-transport-", - ); - - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "claude-config-file", - backend: { - command: "node", - args: ["./fake-claude.mjs"], - }, - workspaceDir, - config: { - plugins: { enabled: false }, - mcp: { - servers: { - context7: { - transport: "streamable-http", - url: "https://mcp.context7.com/mcp", - headers: { CONTEXT7_API_KEY: "ctx7sk-test" }, - }, - "omi-sse": { - transport: "sse", - url: "https://api.omi.me/v1/mcp/sse", - }, - }, - }, - }, - }); - - const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; - expect(configFlagIndex).toBeGreaterThanOrEqual(0); - const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; - const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { - mcpServers?: Record; - }; - - expect(raw.mcpServers?.context7?.type).toBe("http"); - expect(raw.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp"); - expect(raw.mcpServers?.context7?.transport).toBeUndefined(); - - expect(raw.mcpServers?.["omi-sse"]?.type).toBe("sse"); - expect(raw.mcpServers?.["omi-sse"]?.transport).toBeUndefined(); - - await prepared.cleanup?.(); - }); - - it("preserves explicit type and still strips transport on user mcp.servers", async () => { - const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( - "openclaw-cli-bundle-mcp-user-servers-transport-explicit-", - ); - - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "claude-config-file", - backend: { - command: "node", - args: ["./fake-claude.mjs"], - }, - workspaceDir, - config: { - plugins: { enabled: false }, - mcp: { - servers: { - mixed: { - type: "http", - transport: "sse", - url: "https://mcp.example.com/mcp", - }, - }, - }, - }, - }); - - const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; - const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; - const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { - mcpServers?: Record; - }; - - expect(raw.mcpServers?.mixed?.type).toBe("http"); - expect(raw.mcpServers?.mixed?.transport).toBeUndefined(); - - await prepared.cleanup?.(); - }); - - it("user mcp.servers do not override the loopback additionalConfig", async () => { - const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( - "openclaw-cli-bundle-mcp-user-servers-loopback-", - ); - - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "claude-config-file", - backend: { - command: "node", - args: ["./fake-claude.mjs"], - }, - workspaceDir, - config: { - plugins: { enabled: false }, - mcp: { - servers: { - openclaw: { - type: "http", - url: "https://example.com/malicious", - }, - }, - }, - }, - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}" }, - }, - }, - }, - }); - - const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; - expect(configFlagIndex).toBeGreaterThanOrEqual(0); - const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; - const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { - mcpServers?: Record; - }; - expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); - - await prepared.cleanup?.(); - }); - - it("replaces overlapping bundle server entries with user-configured mcp.servers", async () => { - const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( - "openclaw-cli-bundle-mcp-user-servers-replace-", - ); - await writeClaudeBundleManifest({ - homeDir: cliBundleMcpHarness.bundleProbeHomeDir, - pluginId: "omi", - manifest: { name: "omi" }, - }); - const pluginDir = path.join( - cliBundleMcpHarness.bundleProbeHomeDir, - ".openclaw", - "extensions", - "omi", - ); - await fs.writeFile( - path.join(pluginDir, ".mcp.json"), - `${JSON.stringify( - { - mcpServers: { - omi: { - command: process.execPath, - args: [cliBundleMcpHarness.bundleProbeServerPath], - env: { BUNDLE_ONLY: "true" }, - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); - - const env = captureEnv(["HOME"]); - try { - process.env.HOME = cliBundleMcpHarness.bundleProbeHomeDir; - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "claude-config-file", - backend: { - command: "node", - args: ["./fake-claude.mjs"], - }, - workspaceDir, - config: { - plugins: { - entries: { - omi: { enabled: true }, - }, - }, - mcp: { - servers: { - omi: { - type: "sse", - url: "https://api.omi.me/v1/mcp/sse", - headers: { Authorization: "Bearer test-token" }, - }, - }, - }, - }, - }); - - const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; - expect(configFlagIndex).toBeGreaterThanOrEqual(0); - const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; - const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { - mcpServers?: Record< - string, - { - type?: string; - url?: string; - command?: string; - args?: string[]; - env?: Record; - } - >; - }; - expect(raw.mcpServers?.omi?.type).toBe("sse"); - expect(raw.mcpServers?.omi?.url).toBe("https://api.omi.me/v1/mcp/sse"); - expect(raw.mcpServers?.omi?.command).toBeUndefined(); - expect(raw.mcpServers?.omi?.args).toBeUndefined(); - expect(raw.mcpServers?.omi?.env).toBeUndefined(); - - await prepared.cleanup?.(); - } finally { - env.restore(); - } - }); - - it("stabilizes the resume hash when only the OpenClaw loopback port changes", async () => { - const first = await prepareBundleProbeCliConfig({ - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - }, - }, - }, - }, - }); - const second = await prepareBundleProbeCliConfig({ - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:24567/mcp", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - }, - }, - }, - }, - }); - - expect(first.mcpConfigHash).not.toBe(second.mcpConfigHash); - expect(first.mcpResumeHash).toBe(second.mcpResumeHash); - - await first.cleanup?.(); - await second.cleanup?.(); - }); - - it("changes the resume hash when stable MCP semantics change", async () => { - const first = await prepareBundleProbeCliConfig({ - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - }, - }, - }, - }, - }); - const second = await prepareBundleProbeCliConfig({ - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/other", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - }, - }, - }, - }, - }); - - expect(first.mcpResumeHash).not.toBe(second.mcpResumeHash); - - await first.cleanup?.(); - await second.cleanup?.(); - }); - it("preserves extra env values alongside generated MCP config", async () => { const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-env-", diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index d2d179e32c4..abb7529cdb0 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -5,18 +5,13 @@ import path from "node:path"; import { applyMergePatch } from "../../config/merge-patch.js"; import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - extractMcpServerMap, - type BundleMcpConfig, - type BundleMcpServerConfig, -} from "../../plugins/bundle-mcp.js"; +import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-mcp.js"; import type { CliBundleMcpMode } from "../../plugins/types.js"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "../../shared/string-coerce.js"; import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js"; -import { serializeTomlInlineValue } from "./toml-inline.js"; +import { isRecord } from "./bundle-mcp-adapter-shared.js"; +import { findClaudeMcpConfigPath, injectClaudeMcpConfigArgs } from "./bundle-mcp-claude.js"; +import { injectCodexMcpConfigArgs } from "./bundle-mcp-codex.js"; +import { writeGeminiSystemSettings } from "./bundle-mcp-gemini.js"; type PreparedCliBundleMcpConfig = { backend: CliBackendConfig; @@ -39,237 +34,6 @@ async function readExternalMcpConfig(configPath: string): Promise> { - try { - const raw = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown; - return raw && typeof raw === "object" && !Array.isArray(raw) - ? ({ ...raw } as Record) - : {}; - } catch { - return {}; - } -} - -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") { - return normalizeOptionalString(args[i + 1]); - } - if (arg.startsWith("--mcp-config=")) { - return normalizeOptionalString(arg.slice("--mcp-config=".length)); - } - } - return undefined; -} - -function injectClaudeMcpConfigArgs(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; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeStringArray(value: unknown): string[] | undefined { - return Array.isArray(value) && value.every((entry) => typeof entry === "string") - ? [...value] - : undefined; -} - -function normalizeStringRecord(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const entries = Object.entries(value).filter((entry): entry is [string, string] => { - return typeof entry[1] === "string"; - }); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -} - -function decodeHeaderEnvPlaceholder(value: string): { envVar: string; bearer: boolean } | null { - const bearerMatch = /^Bearer \${([A-Z0-9_]+)}$/.exec(value); - if (bearerMatch) { - return { envVar: bearerMatch[1], bearer: true }; - } - const envMatch = /^\${([A-Z0-9_]+)}$/.exec(value); - if (envMatch) { - return { envVar: envMatch[1], bearer: false }; - } - return null; -} - -function applyCommonServerConfig( - next: Record, - server: BundleMcpServerConfig, -): void { - if (typeof server.command === "string") { - next.command = server.command; - } - const args = normalizeStringArray(server.args); - if (args) { - next.args = args; - } - const env = normalizeStringRecord(server.env); - if (env) { - next.env = env; - } - if (typeof server.cwd === "string") { - next.cwd = server.cwd; - } - if (typeof server.url === "string") { - next.url = server.url; - } -} - -function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig): boolean { - return ( - name === "openclaw" && - typeof server.url === "string" && - /^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/mcp(?:[?#].*)?$/.test(server.url) - ); -} - -function normalizeCodexServerConfig( - name: string, - server: BundleMcpServerConfig, -): Record { - const next: Record = {}; - applyCommonServerConfig(next, server); - if (isOpenClawLoopbackMcpServer(name, server)) { - next.default_tools_approval_mode = "approve"; - } - const httpHeaders = normalizeStringRecord(server.headers); - if (httpHeaders) { - const staticHeaders: Record = {}; - const envHeaders: Record = {}; - for (const [name, value] of Object.entries(httpHeaders)) { - const decoded = decodeHeaderEnvPlaceholder(value); - if (!decoded) { - staticHeaders[name] = value; - continue; - } - if (decoded.bearer && normalizeOptionalLowercaseString(name) === "authorization") { - next.bearer_token_env_var = decoded.envVar; - continue; - } - envHeaders[name] = decoded.envVar; - } - if (Object.keys(staticHeaders).length > 0) { - next.http_headers = staticHeaders; - } - if (Object.keys(envHeaders).length > 0) { - next.env_http_headers = envHeaders; - } - } - return next; -} - -function resolveEnvPlaceholder( - value: string, - inheritedEnv: Record | undefined, -): string { - const decoded = decodeHeaderEnvPlaceholder(value); - if (!decoded) { - return value; - } - const resolved = inheritedEnv?.[decoded.envVar] ?? process.env[decoded.envVar] ?? ""; - return decoded.bearer ? `Bearer ${resolved}` : resolved; -} - -function normalizeGeminiServerConfig( - server: BundleMcpServerConfig, - inheritedEnv: Record | undefined, -): Record { - const next: Record = {}; - applyCommonServerConfig(next, server); - if (typeof server.type === "string") { - next.type = server.type; - } - const headers = normalizeStringRecord(server.headers); - if (headers) { - next.headers = Object.fromEntries( - Object.entries(headers).map(([name, value]) => [ - name, - resolveEnvPlaceholder(value, inheritedEnv), - ]), - ); - } - if (typeof server.trust === "boolean") { - next.trust = server.trust; - } - return next; -} - -function injectCodexMcpConfigArgs(args: string[] | undefined, config: BundleMcpConfig): string[] { - const overrides = serializeTomlInlineValue( - Object.fromEntries( - Object.entries(config.mcpServers).map(([name, server]) => [ - name, - normalizeCodexServerConfig(name, server), - ]), - ), - ); - return [...(args ?? []), "-c", `mcp_servers=${overrides}`]; -} - -async function writeGeminiSystemSettings( - mergedConfig: BundleMcpConfig, - inheritedEnv: Record | undefined, -): Promise<{ env: Record; cleanup: () => Promise }> { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gemini-mcp-")); - const settingsPath = path.join(tempDir, "settings.json"); - const existingSettingsPath = - inheritedEnv?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH; - const base = - typeof existingSettingsPath === "string" && existingSettingsPath.trim() - ? await readJsonObject(existingSettingsPath) - : {}; - const normalizedConfig: BundleMcpConfig = { - mcpServers: Object.fromEntries( - Object.entries(mergedConfig.mcpServers).map(([name, server]) => [ - name, - normalizeGeminiServerConfig(server, inheritedEnv), - ]), - ) as BundleMcpConfig["mcpServers"], - }; - const settings = applyMergePatch(base, { - mcp: { - allowed: Object.keys(normalizedConfig.mcpServers), - }, - mcpServers: normalizedConfig.mcpServers, - }) as Record; - await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); - return { - env: { - ...inheritedEnv, - GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath, - }, - cleanup: async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }, - }; -} - function sortJsonValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map((entry) => sortJsonValue(entry)); @@ -393,7 +157,8 @@ export async function prepareCliBundleMcpConfig(params: { const mode = resolveBundleMcpMode(params.mode); const existingMcpConfigPath = mode === "claude-config-file" - ? (findMcpConfigPath(params.backend.resumeArgs) ?? findMcpConfigPath(params.backend.args)) + ? (findClaudeMcpConfigPath(params.backend.resumeArgs) ?? + findClaudeMcpConfigPath(params.backend.args)) : undefined; let mergedConfig: BundleMcpConfig = { mcpServers: {} }; diff --git a/src/agents/cli-runner/bundle-mcp.user-config.test.ts b/src/agents/cli-runner/bundle-mcp.user-config.test.ts new file mode 100644 index 00000000000..66258bd9851 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.user-config.test.ts @@ -0,0 +1,272 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js"; +import { captureEnv } from "../../test-utils/env.js"; +import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; +import { cliBundleMcpHarness, setupCliBundleMcpTestHarness } from "./bundle-mcp.test-support.js"; + +setupCliBundleMcpTestHarness(); + +describe("prepareCliBundleMcpConfig user mcp.servers", () => { + it("merges user-configured mcp.servers from OpenClaw config", async () => { + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-user-servers-", + ); + + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir, + config: { + plugins: { enabled: false }, + mcp: { + servers: { + omi: { + type: "sse", + url: "https://api.omi.me/v1/mcp/sse", + headers: { Authorization: "Bearer test-token" }, + }, + }, + }, + }, + }); + + const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; + expect(configFlagIndex).toBeGreaterThanOrEqual(0); + const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; + const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { + mcpServers?: Record; + }; + expect(raw.mcpServers?.omi?.type).toBe("sse"); + expect(raw.mcpServers?.omi?.url).toBe("https://api.omi.me/v1/mcp/sse"); + + await prepared.cleanup?.(); + }); + + it("translates OpenClaw transport field on user mcp.servers into Claude type", async () => { + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-user-servers-transport-", + ); + + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir, + config: { + plugins: { enabled: false }, + mcp: { + servers: { + context7: { + transport: "streamable-http", + url: "https://mcp.context7.com/mcp", + headers: { CONTEXT7_API_KEY: "ctx7sk-test" }, + }, + "omi-sse": { + transport: "sse", + url: "https://api.omi.me/v1/mcp/sse", + }, + }, + }, + }, + }); + + const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; + expect(configFlagIndex).toBeGreaterThanOrEqual(0); + const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; + const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { + mcpServers?: Record; + }; + + expect(raw.mcpServers?.context7?.type).toBe("http"); + expect(raw.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp"); + expect(raw.mcpServers?.context7?.transport).toBeUndefined(); + + expect(raw.mcpServers?.["omi-sse"]?.type).toBe("sse"); + expect(raw.mcpServers?.["omi-sse"]?.transport).toBeUndefined(); + + await prepared.cleanup?.(); + }); + + it("preserves explicit type and still strips transport on user mcp.servers", async () => { + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-user-servers-transport-explicit-", + ); + + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir, + config: { + plugins: { enabled: false }, + mcp: { + servers: { + mixed: { + type: "http", + transport: "sse", + url: "https://mcp.example.com/mcp", + }, + }, + }, + }, + }); + + const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; + const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; + const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { + mcpServers?: Record; + }; + + expect(raw.mcpServers?.mixed?.type).toBe("http"); + expect(raw.mcpServers?.mixed?.transport).toBeUndefined(); + + await prepared.cleanup?.(); + }); + + it("user mcp.servers do not override the loopback additionalConfig", async () => { + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-user-servers-loopback-", + ); + + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir, + config: { + plugins: { enabled: false }, + mcp: { + servers: { + openclaw: { + type: "http", + url: "https://example.com/malicious", + }, + }, + }, + }, + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}" }, + }, + }, + }, + }); + + const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; + expect(configFlagIndex).toBeGreaterThanOrEqual(0); + const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; + const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { + mcpServers?: Record; + }; + expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); + + await prepared.cleanup?.(); + }); + + it("replaces overlapping bundle server entries with user-configured mcp.servers", async () => { + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-user-servers-replace-", + ); + await writeClaudeBundleManifest({ + homeDir: cliBundleMcpHarness.bundleProbeHomeDir, + pluginId: "omi", + manifest: { name: "omi" }, + }); + const pluginDir = path.join( + cliBundleMcpHarness.bundleProbeHomeDir, + ".openclaw", + "extensions", + "omi", + ); + await fs.writeFile( + path.join(pluginDir, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + omi: { + command: process.execPath, + args: [cliBundleMcpHarness.bundleProbeServerPath], + env: { BUNDLE_ONLY: "true" }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const env = captureEnv(["HOME"]); + try { + process.env.HOME = cliBundleMcpHarness.bundleProbeHomeDir; + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir, + config: { + plugins: { + entries: { + omi: { enabled: true }, + }, + }, + mcp: { + servers: { + omi: { + type: "sse", + url: "https://api.omi.me/v1/mcp/sse", + headers: { Authorization: "Bearer test-token" }, + }, + }, + }, + }, + }); + + const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; + expect(configFlagIndex).toBeGreaterThanOrEqual(0); + const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; + const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { + mcpServers?: Record< + string, + { + type?: string; + url?: string; + command?: string; + args?: string[]; + env?: Record; + } + >; + }; + expect(raw.mcpServers?.omi?.type).toBe("sse"); + expect(raw.mcpServers?.omi?.url).toBe("https://api.omi.me/v1/mcp/sse"); + expect(raw.mcpServers?.omi?.command).toBeUndefined(); + expect(raw.mcpServers?.omi?.args).toBeUndefined(); + expect(raw.mcpServers?.omi?.env).toBeUndefined(); + + await prepared.cleanup?.(); + } finally { + env.restore(); + } + }); +});