fix(cli-runtime): translate MCP transports for CLI backends

Translate OpenClaw `mcp.servers.*.transport` entries into the downstream Claude/Gemini CLI `type` field before writing bundle MCP config.

Also keeps the plugin-sdk bundled-entry fast-path fixture unambiguously CommonJS on Node 24 after runtime-deps mirroring adds a `type: "module"` boundary.

Co-authored-by: Blockchain-Oracle <ajweb3dev@gmail.com>
This commit is contained in:
Blockchain Oracle
2026-04-25 21:08:04 +01:00
committed by GitHub
parent 02f3e9cfa2
commit b40df76c18
4 changed files with 188 additions and 2 deletions

View File

@@ -95,6 +95,9 @@ Docs: https://docs.openclaw.ai
equivalent transcripts.
- Agents/replies: forward sanitized underlying agent failure details on external
channels instead of replacing unknown failures with a generic retry message.
- CLI/MCP: translate OpenClaw `mcp.servers.*.transport` entries into
Claude/Gemini CLI `type` fields so streamable HTTP MCP servers load in CLI
backend sessions. (#71724) Thanks @Blockchain-Oracle.
- Browser/CDP: honor configured remote and `attachOnly` CDP HTTP/WebSocket
timeouts when opening tabs through raw CDP or `/json/new` fallback. (#54238)
Thanks @FuncWei.

View File

@@ -227,6 +227,93 @@ describe("prepareCliBundleMcpConfig", () => {
await prepared.cleanup?.();
});
it("translates OpenClaw transport field on user mcp.servers into Claude type", async () => {
const workspaceDir = await 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 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 tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-user-servers-loopback-",
@@ -548,4 +635,52 @@ describe("prepareCliBundleMcpConfig", () => {
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

@@ -91,6 +91,45 @@ 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]
@@ -418,10 +457,18 @@ export async function prepareCliBundleMcpConfig(params: {
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, ...configuredMcp } : configuredMcp,
mcpServers: existingMcpServers
? { ...existingMcpServers, ...translatedConfiguredMcp }
: translatedConfiguredMcp,
} satisfies BundleMcpConfig;
}
if (params.additionalConfig) {

View File

@@ -172,7 +172,8 @@ async function expectBuiltArtifactNodeRequireFastPath(
const importerPath = path.join(pluginRoot, "index.js");
const sidecarPath = path.join(pluginRoot, "fast-path-sidecar.cjs");
fs.writeFileSync(importerPath, "export default {};\n", "utf8");
// CommonJS so `nodeRequire` succeeds without falling back to jiti.
// CommonJS so `nodeRequire` succeeds without falling back to jiti, even
// after runtime-deps mirroring writes a `type: "module"` package boundary.
fs.writeFileSync(sidecarPath, "module.exports = { sentinel: 7 };\n", "utf8");
expect(