mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
refactor(agents): share bundle MCP config merging
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
64
src/agents/bundle-mcp-config.test.ts
Normal file
64
src/agents/bundle-mcp-config.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/agents/bundle-mcp-config.ts
Normal file
76
src/agents/bundle-mcp-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
src/agents/cli-runner/bundle-mcp.codex.test.ts
Normal file
45
src/agents/cli-runner/bundle-mcp.codex.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/agents/cli-runner/bundle-mcp.gemini.live.test.ts
Normal file
72
src/agents/cli-runner/bundle-mcp.gemini.live.test.ts
Normal 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);
|
||||
});
|
||||
95
src/agents/cli-runner/bundle-mcp.gemini.test.ts
Normal file
95
src/agents/cli-runner/bundle-mcp.gemini.test.ts
Normal 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?.();
|
||||
});
|
||||
});
|
||||
75
src/agents/cli-runner/bundle-mcp.test-support.ts
Normal file
75
src/agents/cli-runner/bundle-mcp.test-support.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user