mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
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:
committed by
GitHub
parent
02f3e9cfa2
commit
b40df76c18
@@ -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.
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user