refactor(agents): share bundle MCP config merging

This commit is contained in:
Peter Steinberger
2026-04-25 21:36:18 +01:00
parent c3a3ceefbe
commit dcfd5913fd
11 changed files with 486 additions and 269 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",
});
});
});

View File

@@ -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<string, string> = {
"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<string, unknown>;
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,
};
}

View File

@@ -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();
});
});

View File

@@ -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<boolean> {
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);
});

View File

@@ -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<string, { url?: string; headers?: Record<string, string> }>;
};
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<string, string> }
>;
};
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?.();
});
});

View File

@@ -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<typeof captureEnv> | 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<typeof prepareCliBundleMcpConfig>[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();
}
}

View File

@@ -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<typeof captureEnv> | 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<typeof prepareCliBundleMcpConfig>[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<string, { args?: string[] }>;
};
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<string, { url?: string; headers?: Record<string, string> }>;
};
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<string, string> }
>;
};
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?.();
});
});

View File

@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const OPENCLAW_TRANSPORT_TO_BUNDLE_TYPE: Record<string, string> = {
"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<string, unknown>;
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;
}

View File

@@ -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<string, BundleMcpServerConfig>;
@@ -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,
};
}