From b40df76c186ee51f3f590e949d928f46059d2ffa Mon Sep 17 00:00:00 2001 From: Blockchain Oracle Date: Sat, 25 Apr 2026 21:08:04 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 3 + src/agents/cli-runner/bundle-mcp.test.ts | 135 ++++++++++++++++++ src/agents/cli-runner/bundle-mcp.ts | 49 ++++++- src/plugin-sdk/channel-entry-contract.test.ts | 3 +- 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7aefad616..876fe0797fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index ffddede3fc9..b4d685eb8f2 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -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; + }; + + 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; + }; + + 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 } + >; + }; + expect(raw.mcp?.allowed).toEqual(["context7"]); + expect(raw.mcpServers?.context7?.type).toBe("http"); + expect(raw.mcpServers?.context7?.transport).toBeUndefined(); + expect(raw.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp"); + expect(raw.mcpServers?.context7?.headers?.Authorization).toBe("Bearer ctx7-test"); + + await prepared.cleanup?.(); + }); }); diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index d6425b160c6..db1837f48d7 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -91,6 +91,45 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +const OPENCLAW_TRANSPORT_TO_BUNDLE_TYPE: Record = { + "streamable-http": "http", + http: "http", + sse: "sse", + stdio: "stdio", +}; + +/** + * Translate the OpenClaw `transport` field on an MCP server entry into the + * `type` field expected by downstream CLI runners (Claude, Gemini). The + * OpenClaw config schema (`McpServerConfig.transport`) accepts + * `"sse" | "streamable-http"`, while Claude Code and Gemini expect + * `type: "http" | "sse" | "stdio"`. Without this translation, user-defined + * HTTP MCP servers from `mcp.servers` are written into the bundled CLI config + * with an unrecognized `transport` key and rejected (or silently treated as + * stdio) by the downstream CLI. + * + * If both `transport` and `type` are set, `type` wins (explicit downstream + * override). The `transport` key is removed from the result either way so it + * does not leak into the downstream CLI config. + */ +function translateOpenClawTransportToBundleType( + server: BundleMcpServerConfig, +): BundleMcpServerConfig { + const next = { ...server } as Record; + const rawTransport = next.transport; + delete next.transport; + if (typeof next.type === "string") { + return next as BundleMcpServerConfig; + } + if (typeof rawTransport === "string") { + const mapped = OPENCLAW_TRANSPORT_TO_BUNDLE_TYPE[rawTransport]; + if (mapped) { + next.type = mapped; + } + } + return next as BundleMcpServerConfig; +} + function normalizeStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? [...value] @@ -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) { diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 187b06331a9..e9ccfb891a3 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -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(