From dcfd5913fd24a7fce8fe804bf687f4d4856a6652 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 21:36:18 +0100 Subject: [PATCH] refactor(agents): share bundle MCP config merging --- docs/cli/mcp.md | 5 + docs/help/testing-live.md | 12 + src/agents/bundle-mcp-config.test.ts | 64 +++++ src/agents/bundle-mcp-config.ts | 76 ++++++ .../cli-runner/bundle-mcp.codex.test.ts | 45 ++++ .../cli-runner/bundle-mcp.gemini.live.test.ts | 72 ++++++ .../cli-runner/bundle-mcp.gemini.test.ts | 95 +++++++ .../cli-runner/bundle-mcp.test-support.ts | 75 ++++++ src/agents/cli-runner/bundle-mcp.test.ts | 238 +++--------------- src/agents/cli-runner/bundle-mcp.ts | 61 +---- src/agents/embedded-pi-mcp.ts | 12 +- 11 files changed, 486 insertions(+), 269 deletions(-) create mode 100644 src/agents/bundle-mcp-config.test.ts create mode 100644 src/agents/bundle-mcp-config.ts create mode 100644 src/agents/cli-runner/bundle-mcp.codex.test.ts create mode 100644 src/agents/cli-runner/bundle-mcp.gemini.live.test.ts create mode 100644 src/agents/cli-runner/bundle-mcp.gemini.test.ts create mode 100644 src/agents/cli-runner/bundle-mcp.test-support.ts diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 4f98e70af2b..312af52e46f 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -384,6 +384,11 @@ Important behavior: milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end +Runtime adapters may normalize this shared registry into the shape their +downstream client expects. For example, embedded Pi consumes OpenClaw +`transport` values directly, while Claude Code and Gemini receive CLI-native +`type` values such as `http`, `sse`, or `stdio`. + ## Saved MCP server definitions OpenClaw also stores a lightweight MCP server registry in config for surfaces diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 38d661bc731..cd5194dd98b 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -164,6 +164,18 @@ OPENCLAW_LIVE_CLI_BACKEND=1 \ pnpm test:live src/gateway/gateway-cli-backend.live.test.ts ``` +Cheap Gemini MCP config smoke: + +```bash +OPENCLAW_LIVE_TEST=1 \ + pnpm test:live src/agents/cli-runner/bundle-mcp.gemini.live.test.ts +``` + +This does not ask Gemini to generate a response. It writes the same system +settings OpenClaw gives Gemini, then runs `gemini --debug mcp list` to prove a +saved `transport: "streamable-http"` server is normalized to Gemini's HTTP MCP +shape and can connect. + Docker recipe: ```bash diff --git a/src/agents/bundle-mcp-config.test.ts b/src/agents/bundle-mcp-config.test.ts new file mode 100644 index 00000000000..b05cb4f08dd --- /dev/null +++ b/src/agents/bundle-mcp-config.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createBundleMcpTempHarness, + createBundleProbePlugin, + withBundleHomeEnv, +} from "../plugins/bundle-mcp.test-support.js"; +import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "./bundle-mcp-config.js"; + +const tempHarness = createBundleMcpTempHarness(); + +afterEach(async () => { + await tempHarness.cleanup(); +}); + +describe("loadMergedBundleMcpConfig", () => { + it("lets OpenClaw mcp.servers override bundle defaults while preserving raw transport shape", async () => { + await withBundleHomeEnv( + tempHarness, + "openclaw-bundle-mcp-config", + async ({ homeDir, workspaceDir }) => { + await createBundleProbePlugin(homeDir); + + const merged = loadMergedBundleMcpConfig({ + workspaceDir, + cfg: { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + mcp: { + servers: { + bundleProbe: { + transport: "streamable-http", + url: "https://mcp.example.com/mcp", + }, + }, + }, + }, + }); + + expect(merged.config.mcpServers.bundleProbe).toEqual({ + transport: "streamable-http", + url: "https://mcp.example.com/mcp", + }); + }, + ); + }); + + it("maps OpenClaw transports to downstream CLI types when requested", () => { + expect( + toCliBundleMcpServerConfig({ + transport: "streamable-http", + url: "https://mcp.example.com/mcp", + }), + ).toEqual({ + type: "http", + url: "https://mcp.example.com/mcp", + }); + expect(toCliBundleMcpServerConfig({ type: "sse", transport: "streamable-http" })).toEqual({ + type: "sse", + }); + }); +}); diff --git a/src/agents/bundle-mcp-config.ts b/src/agents/bundle-mcp-config.ts new file mode 100644 index 00000000000..fa8fbea4e28 --- /dev/null +++ b/src/agents/bundle-mcp-config.ts @@ -0,0 +1,76 @@ +import { normalizeConfiguredMcpServers } from "../config/mcp-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + loadEnabledBundleMcpConfig, + type BundleMcpConfig, + type BundleMcpDiagnostic, + type BundleMcpServerConfig, +} from "../plugins/bundle-mcp.js"; + +export type MergedBundleMcpConfig = { + config: BundleMcpConfig; + diagnostics: BundleMcpDiagnostic[]; +}; + +export type BundleMcpServerMapper = ( + server: BundleMcpServerConfig, + name: string, +) => BundleMcpServerConfig; + +const OPENCLAW_TRANSPORT_TO_CLI_BUNDLE_TYPE: Record = { + "streamable-http": "http", + http: "http", + sse: "sse", + stdio: "stdio", +}; + +/** + * User config stores OpenClaw MCP transport names, while CLI backends such as + * Claude Code and Gemini expect a downstream `type` field. Keep this adapter + * out of the generic merge path because embedded Pi still consumes the raw + * OpenClaw `transport` shape directly. + */ +export function toCliBundleMcpServerConfig(server: BundleMcpServerConfig): BundleMcpServerConfig { + const next = { ...server } as Record; + const rawTransport = next.transport; + delete next.transport; + if (typeof next.type === "string") { + return next as BundleMcpServerConfig; + } + if (typeof rawTransport === "string") { + const mapped = OPENCLAW_TRANSPORT_TO_CLI_BUNDLE_TYPE[rawTransport]; + if (mapped) { + next.type = mapped; + } + } + return next as BundleMcpServerConfig; +} + +export function loadMergedBundleMcpConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + mapConfiguredServer?: BundleMcpServerMapper; +}): MergedBundleMcpConfig { + const bundleMcp = loadEnabledBundleMcpConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers); + const mapConfiguredServer = params.mapConfiguredServer ?? ((server) => server); + + return { + config: { + // OpenClaw config is the owner-managed layer, so it overrides bundle defaults. + mcpServers: { + ...bundleMcp.config.mcpServers, + ...Object.fromEntries( + Object.entries(configuredMcp).map(([name, server]) => [ + name, + mapConfiguredServer(server as BundleMcpServerConfig, name), + ]), + ), + } satisfies BundleMcpConfig["mcpServers"], + }, + diagnostics: bundleMcp.diagnostics, + }; +} diff --git a/src/agents/cli-runner/bundle-mcp.codex.test.ts b/src/agents/cli-runner/bundle-mcp.codex.test.ts new file mode 100644 index 00000000000..e18dac77a0d --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.codex.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; + +describe("prepareCliBundleMcpConfig codex", () => { + it("injects codex MCP config overrides with env-backed loopback headers", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "codex-config-overrides", + backend: { + command: "codex", + args: ["exec", "--json"], + resumeArgs: ["exec", "resume", "{sessionId}"], + }, + workspaceDir: "/tmp/openclaw-bundle-mcp-codex", + config: { plugins: { enabled: false } }, + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}", + }, + }, + }, + }, + }); + + expect(prepared.backend.args).toEqual([ + "exec", + "--json", + "-c", + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', + ]); + expect(prepared.backend.resumeArgs).toEqual([ + "exec", + "resume", + "{sessionId}", + "-c", + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', + ]); + expect(prepared.cleanup).toBeUndefined(); + }); +}); diff --git a/src/agents/cli-runner/bundle-mcp.gemini.live.test.ts b/src/agents/cli-runner/bundle-mcp.gemini.live.test.ts new file mode 100644 index 00000000000..2e312ad8199 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.gemini.live.test.ts @@ -0,0 +1,72 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { isLiveTestEnabled } from "../live-test-helpers.js"; +import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; + +const execFileAsync = promisify(execFile); +const LIVE = isLiveTestEnabled(["OPENCLAW_LIVE_CLI_MCP_GEMINI"]); +const describeLive = LIVE ? describe : describe.skip; + +async function canRunGemini(command: string): Promise { + try { + await execFileAsync(command, ["--version"], { timeout: 10_000 }); + return true; + } catch { + return false; + } +} + +describeLive("Gemini CLI MCP settings smoke", () => { + it("connects to an OpenClaw-configured streamable-http server", async () => { + const geminiCommand = process.env.OPENCLAW_LIVE_GEMINI_COMMAND ?? "gemini"; + if (!(await canRunGemini(geminiCommand))) { + console.warn(`Skipping Gemini MCP live smoke: ${geminiCommand} is not runnable.`); + return; + } + + const inheritedEnv = + typeof process.env.CONTEXT7_API_KEY === "string" && process.env.CONTEXT7_API_KEY + ? { CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY } + : undefined; + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "gemini-system-settings", + backend: { + command: geminiCommand, + args: ["--prompt", "{prompt}"], + }, + workspaceDir: process.cwd(), + config: { + plugins: { enabled: false }, + mcp: { + servers: { + context7: { + transport: "streamable-http", + url: "https://mcp.context7.com/mcp", + ...(inheritedEnv ? { headers: { Authorization: "Bearer ${CONTEXT7_API_KEY}" } } : {}), + }, + }, + }, + }, + env: inheritedEnv, + }); + + try { + const result = await execFileAsync(geminiCommand, ["--debug", "mcp", "list"], { + env: { + ...process.env, + ...prepared.env, + }, + timeout: 45_000, + maxBuffer: 1024 * 1024, + }); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("context7"); + expect(output).toMatch(/\(http\)|type:\s*http|http/i); + expect(output).not.toContain("transport"); + } finally { + await prepared.cleanup?.(); + } + }, 60_000); +}); diff --git a/src/agents/cli-runner/bundle-mcp.gemini.test.ts b/src/agents/cli-runner/bundle-mcp.gemini.test.ts new file mode 100644 index 00000000000..b72b672878b --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.gemini.test.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; + +describe("prepareCliBundleMcpConfig gemini", () => { + it("writes Gemini system settings for bundle MCP servers", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "gemini-system-settings", + backend: { + command: "gemini", + args: ["--prompt", "{prompt}"], + }, + workspaceDir: "/tmp/openclaw-bundle-mcp-gemini", + config: { plugins: { enabled: false } }, + additionalConfig: { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + }, + }, + }, + }, + env: { + OPENCLAW_MCP_TOKEN: "loopback-token-123", + }, + }); + + expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]); + expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("loopback-token-123"); + expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string"); + const raw = JSON.parse( + await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), + ) as { + mcp?: { allowed?: string[] }; + mcpServers?: Record }>; + }; + expect(raw.mcp?.allowed).toEqual(["openclaw"]); + expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); + expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123"); + + await prepared.cleanup?.(); + }); + + it("translates user mcp.servers transport fields in Gemini system settings", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "gemini-system-settings", + backend: { + command: "gemini", + args: ["--prompt", "{prompt}"], + }, + workspaceDir: "/tmp/openclaw-bundle-mcp-gemini", + config: { + plugins: { enabled: false }, + mcp: { + servers: { + context7: { + transport: "streamable-http", + url: "https://mcp.context7.com/mcp", + headers: { + Authorization: "Bearer ${CONTEXT7_API_KEY}", + }, + }, + }, + }, + }, + env: { + CONTEXT7_API_KEY: "ctx7-test", + }, + }); + + expect(prepared.env?.CONTEXT7_API_KEY).toBe("ctx7-test"); + expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string"); + const raw = JSON.parse( + await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), + ) as { + mcp?: { allowed?: string[] }; + mcpServers?: Record< + string, + { type?: string; transport?: string; url?: string; headers?: Record } + >; + }; + expect(raw.mcp?.allowed).toEqual(["context7"]); + expect(raw.mcpServers?.context7?.type).toBe("http"); + expect(raw.mcpServers?.context7?.transport).toBeUndefined(); + expect(raw.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp"); + expect(raw.mcpServers?.context7?.headers?.Authorization).toBe("Bearer ctx7-test"); + + await prepared.cleanup?.(); + }); +}); diff --git a/src/agents/cli-runner/bundle-mcp.test-support.ts b/src/agents/cli-runner/bundle-mcp.test-support.ts new file mode 100644 index 00000000000..c9e9a53e3f2 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.test-support.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + createBundleMcpTempHarness, + createBundleProbePlugin, +} from "../../plugins/bundle-mcp.test-support.js"; +import { captureEnv } from "../../test-utils/env.js"; +import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; + +const tempHarness = createBundleMcpTempHarness(); +let bundleProbeHomeDir = ""; +let bundleProbeWorkspaceDir = ""; +let bundleProbeServerPath = ""; +let envSnapshot: ReturnType | undefined; + +export const cliBundleMcpHarness = { + tempHarness, + get bundleProbeHomeDir() { + return bundleProbeHomeDir; + }, + get bundleProbeWorkspaceDir() { + return bundleProbeWorkspaceDir; + }, + get bundleProbeServerPath() { + return bundleProbeServerPath; + }, +}; + +export function setupCliBundleMcpTestHarness(): void { + beforeAll(async () => { + envSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]); + bundleProbeHomeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-"); + bundleProbeWorkspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-"); + const emptyBundledDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-bundled-"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = emptyBundledDir; + ({ serverPath: bundleProbeServerPath } = await createBundleProbePlugin(bundleProbeHomeDir)); + }); + + afterAll(async () => { + envSnapshot?.restore(); + await tempHarness.cleanup(); + }); +} + +export function createEnabledBundleProbeConfig(): OpenClawConfig { + return { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }; +} + +export async function prepareBundleProbeCliConfig(params?: { + additionalConfig?: Parameters[0]["additionalConfig"]; +}) { + const env = captureEnv(["HOME"]); + try { + process.env.HOME = bundleProbeHomeDir; + return await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir: bundleProbeWorkspaceDir, + config: createEnabledBundleProbeConfig(), + additionalConfig: params?.additionalConfig, + }); + } finally { + env.restore(); + } +} diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index b4d685eb8f2..34b05db5c82 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -1,70 +1,22 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - createBundleMcpTempHarness, - createBundleProbePlugin, - writeClaudeBundleManifest, -} from "../../plugins/bundle-mcp.test-support.js"; +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, + prepareBundleProbeCliConfig, + setupCliBundleMcpTestHarness, +} from "./bundle-mcp.test-support.js"; -const tempHarness = createBundleMcpTempHarness(); -let bundleProbeHomeDir = ""; -let bundleProbeWorkspaceDir = ""; -let bundleProbeServerPath = ""; -let envSnapshot: ReturnType | undefined; - -beforeAll(async () => { - envSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]); - bundleProbeHomeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-"); - bundleProbeWorkspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-"); - const emptyBundledDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-bundled-"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = emptyBundledDir; - ({ serverPath: bundleProbeServerPath } = await createBundleProbePlugin(bundleProbeHomeDir)); -}); - -afterAll(async () => { - envSnapshot?.restore(); - await tempHarness.cleanup(); -}); - -function createEnabledBundleProbeConfig(): OpenClawConfig { - return { - plugins: { - entries: { - "bundle-probe": { enabled: true }, - }, - }, - }; -} - -async function prepareBundleProbeCliConfig(params?: { - additionalConfig?: Parameters[0]["additionalConfig"]; -}) { - const env = captureEnv(["HOME"]); - try { - process.env.HOME = bundleProbeHomeDir; - return await prepareCliBundleMcpConfig({ - enabled: true, - mode: "claude-config-file", - backend: { - command: "node", - args: ["./fake-claude.mjs"], - }, - workspaceDir: bundleProbeWorkspaceDir, - config: createEnabledBundleProbeConfig(), - additionalConfig: params?.additionalConfig, - }); - } finally { - env.restore(); - } -} +setupCliBundleMcpTestHarness(); describe("prepareCliBundleMcpConfig", () => { it("injects a strict empty --mcp-config overlay for bundle-MCP-enabled backends without servers", async () => { - const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-empty-"); + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-empty-", + ); const prepared = await prepareCliBundleMcpConfig({ enabled: true, @@ -101,7 +53,9 @@ describe("prepareCliBundleMcpConfig", () => { const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { mcpServers?: Record; }; - expect(raw.mcpServers?.bundleProbe?.args).toEqual([await fs.realpath(bundleProbeServerPath)]); + expect(raw.mcpServers?.bundleProbe?.args).toEqual([ + await fs.realpath(cliBundleMcpHarness.bundleProbeServerPath), + ]); expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/); expect(prepared.mcpResumeHash).toMatch(/^[0-9a-f]{64}$/); @@ -109,7 +63,9 @@ describe("prepareCliBundleMcpConfig", () => { }); it("loads workspace bundle MCP plugins from the configured workspace root", async () => { - const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-root-"); + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-workspace-root-", + ); const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "workspace-probe"); const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); await fs.mkdir(path.dirname(serverPath), { recursive: true }); @@ -191,7 +147,9 @@ describe("prepareCliBundleMcpConfig", () => { }); it("merges user-configured mcp.servers from OpenClaw config", async () => { - const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-user-servers-"); + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-user-servers-", + ); const prepared = await prepareCliBundleMcpConfig({ enabled: true, @@ -228,7 +186,7 @@ describe("prepareCliBundleMcpConfig", () => { }); it("translates OpenClaw transport field on user mcp.servers into Claude type", async () => { - const workspaceDir = await tempHarness.createTempDir( + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-user-servers-transport-", ); @@ -276,7 +234,7 @@ describe("prepareCliBundleMcpConfig", () => { }); it("preserves explicit type and still strips transport on user mcp.servers", async () => { - const workspaceDir = await tempHarness.createTempDir( + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-user-servers-transport-explicit-", ); @@ -315,7 +273,7 @@ describe("prepareCliBundleMcpConfig", () => { }); it("user mcp.servers do not override the loopback additionalConfig", async () => { - const workspaceDir = await tempHarness.createTempDir( + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-user-servers-loopback-", ); @@ -361,15 +319,20 @@ describe("prepareCliBundleMcpConfig", () => { }); it("replaces overlapping bundle server entries with user-configured mcp.servers", async () => { - const workspaceDir = await tempHarness.createTempDir( + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-user-servers-replace-", ); await writeClaudeBundleManifest({ - homeDir: bundleProbeHomeDir, + homeDir: cliBundleMcpHarness.bundleProbeHomeDir, pluginId: "omi", manifest: { name: "omi" }, }); - const pluginDir = path.join(bundleProbeHomeDir, ".openclaw", "extensions", "omi"); + const pluginDir = path.join( + cliBundleMcpHarness.bundleProbeHomeDir, + ".openclaw", + "extensions", + "omi", + ); await fs.writeFile( path.join(pluginDir, ".mcp.json"), `${JSON.stringify( @@ -377,7 +340,7 @@ describe("prepareCliBundleMcpConfig", () => { mcpServers: { omi: { command: process.execPath, - args: [bundleProbeServerPath], + args: [cliBundleMcpHarness.bundleProbeServerPath], env: { BUNDLE_ONLY: "true" }, }, }, @@ -390,7 +353,7 @@ describe("prepareCliBundleMcpConfig", () => { const env = captureEnv(["HOME"]); try { - process.env.HOME = bundleProbeHomeDir; + process.env.HOME = cliBundleMcpHarness.bundleProbeHomeDir; const prepared = await prepareCliBundleMcpConfig({ enabled: true, mode: "claude-config-file", @@ -514,7 +477,9 @@ describe("prepareCliBundleMcpConfig", () => { }); it("preserves extra env values alongside generated MCP config", async () => { - const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-env-"); + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-env-", + ); const prepared = await prepareCliBundleMcpConfig({ enabled: true, @@ -552,135 +517,4 @@ describe("prepareCliBundleMcpConfig", () => { expect(prepared.backend.args).toEqual(["./fake-cli.mjs"]); expect(prepared.cleanup).toBeUndefined(); }); - - it("injects codex MCP config overrides with env-backed loopback headers", async () => { - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "codex-config-overrides", - backend: { - command: "codex", - args: ["exec", "--json"], - resumeArgs: ["exec", "resume", "{sessionId}"], - }, - workspaceDir: "/tmp/openclaw-bundle-mcp-codex", - config: { plugins: { enabled: false } }, - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}", - }, - }, - }, - }, - }); - - expect(prepared.backend.args).toEqual([ - "exec", - "--json", - "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', - ]); - expect(prepared.backend.resumeArgs).toEqual([ - "exec", - "resume", - "{sessionId}", - "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", default_tools_approval_mode = "approve", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', - ]); - expect(prepared.cleanup).toBeUndefined(); - }); - - it("writes Gemini system settings for bundle MCP servers", async () => { - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "gemini-system-settings", - backend: { - command: "gemini", - args: ["--prompt", "{prompt}"], - }, - workspaceDir: "/tmp/openclaw-bundle-mcp-gemini", - config: { plugins: { enabled: false } }, - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - }, - }, - }, - }, - env: { - OPENCLAW_MCP_TOKEN: "loopback-token-123", - }, - }); - - expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]); - expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("loopback-token-123"); - expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string"); - const raw = JSON.parse( - await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), - ) as { - mcp?: { allowed?: string[] }; - mcpServers?: Record }>; - }; - expect(raw.mcp?.allowed).toEqual(["openclaw"]); - expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); - expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123"); - - await prepared.cleanup?.(); - }); - - it("translates user mcp.servers transport fields in Gemini system settings", async () => { - const prepared = await prepareCliBundleMcpConfig({ - enabled: true, - mode: "gemini-system-settings", - backend: { - command: "gemini", - args: ["--prompt", "{prompt}"], - }, - workspaceDir: "/tmp/openclaw-bundle-mcp-gemini", - config: { - plugins: { enabled: false }, - mcp: { - servers: { - context7: { - transport: "streamable-http", - url: "https://mcp.context7.com/mcp", - headers: { - Authorization: "Bearer ${CONTEXT7_API_KEY}", - }, - }, - }, - }, - }, - env: { - CONTEXT7_API_KEY: "ctx7-test", - }, - }); - - expect(prepared.env?.CONTEXT7_API_KEY).toBe("ctx7-test"); - expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string"); - const raw = JSON.parse( - await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), - ) as { - mcp?: { allowed?: string[] }; - mcpServers?: Record< - string, - { type?: string; transport?: string; url?: string; headers?: Record } - >; - }; - expect(raw.mcp?.allowed).toEqual(["context7"]); - expect(raw.mcpServers?.context7?.type).toBe("http"); - expect(raw.mcpServers?.context7?.transport).toBeUndefined(); - expect(raw.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp"); - expect(raw.mcpServers?.context7?.headers?.Authorization).toBe("Bearer ctx7-test"); - - await prepared.cleanup?.(); - }); }); diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index db1837f48d7..d2d179e32c4 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -2,13 +2,11 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { normalizeConfiguredMcpServers } from "../../config/mcp-config.js"; import { applyMergePatch } from "../../config/merge-patch.js"; import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { extractMcpServerMap, - loadEnabledBundleMcpConfig, type BundleMcpConfig, type BundleMcpServerConfig, } from "../../plugins/bundle-mcp.js"; @@ -17,6 +15,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js"; import { serializeTomlInlineValue } from "./toml-inline.js"; type PreparedCliBundleMcpConfig = { @@ -91,45 +90,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -const OPENCLAW_TRANSPORT_TO_BUNDLE_TYPE: Record = { - "streamable-http": "http", - http: "http", - sse: "sse", - stdio: "stdio", -}; - -/** - * Translate the OpenClaw `transport` field on an MCP server entry into the - * `type` field expected by downstream CLI runners (Claude, Gemini). The - * OpenClaw config schema (`McpServerConfig.transport`) accepts - * `"sse" | "streamable-http"`, while Claude Code and Gemini expect - * `type: "http" | "sse" | "stdio"`. Without this translation, user-defined - * HTTP MCP servers from `mcp.servers` are written into the bundled CLI config - * with an unrecognized `transport` key and rejected (or silently treated as - * stdio) by the downstream CLI. - * - * If both `transport` and `type` are set, `type` wins (explicit downstream - * override). The `transport` key is removed from the result either way so it - * does not leak into the downstream CLI config. - */ -function translateOpenClawTransportToBundleType( - server: BundleMcpServerConfig, -): BundleMcpServerConfig { - const next = { ...server } as Record; - const rawTransport = next.transport; - delete next.transport; - if (typeof next.type === "string") { - return next as BundleMcpServerConfig; - } - if (typeof rawTransport === "string") { - const mapped = OPENCLAW_TRANSPORT_TO_BUNDLE_TYPE[rawTransport]; - if (mapped) { - next.type = mapped; - } - } - return next as BundleMcpServerConfig; -} - function normalizeStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? [...value] @@ -447,30 +407,15 @@ export async function prepareCliBundleMcpConfig(params: { ) as BundleMcpConfig; } - const bundleConfig = loadEnabledBundleMcpConfig({ + const bundleConfig = loadMergedBundleMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.config, + mapConfiguredServer: toCliBundleMcpServerConfig, }); for (const diagnostic of bundleConfig.diagnostics) { params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`); } mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig; - const configuredMcp = normalizeConfiguredMcpServers(params.config?.mcp?.servers); - if (Object.keys(configuredMcp).length > 0) { - const translatedConfiguredMcp = Object.fromEntries( - Object.entries(configuredMcp).map(([name, server]) => [ - name, - translateOpenClawTransportToBundleType(server as BundleMcpServerConfig), - ]), - ) as BundleMcpConfig["mcpServers"]; - const existingMcpServers = mergedConfig.mcpServers; - mergedConfig = { - ...mergedConfig, - mcpServers: existingMcpServers - ? { ...existingMcpServers, ...translatedConfiguredMcp } - : translatedConfiguredMcp, - } satisfies BundleMcpConfig; - } if (params.additionalConfig) { mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig; } diff --git a/src/agents/embedded-pi-mcp.ts b/src/agents/embedded-pi-mcp.ts index 326fe538ce1..04d89a81add 100644 --- a/src/agents/embedded-pi-mcp.ts +++ b/src/agents/embedded-pi-mcp.ts @@ -1,7 +1,6 @@ -import { normalizeConfiguredMcpServers } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js"; -import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js"; +import { loadMergedBundleMcpConfig } from "./bundle-mcp-config.js"; export type EmbeddedPiMcpConfig = { mcpServers: Record; @@ -12,18 +11,13 @@ export function loadEmbeddedPiMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; }): EmbeddedPiMcpConfig { - const bundleMcp = loadEnabledBundleMcpConfig({ + const bundleMcp = loadMergedBundleMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, }); - const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers); return { - // OpenClaw config is the owner-managed layer, so it overrides bundle defaults. - mcpServers: { - ...bundleMcp.config.mcpServers, - ...configuredMcp, - }, + mcpServers: bundleMcp.config.mcpServers, diagnostics: bundleMcp.diagnostics, }; }