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