fix: restore claude cli loopback mcp bridge (#35676) (thanks @mylukin)

This commit is contained in:
Peter Steinberger
2026-04-04 15:14:22 +09:00
parent c2435306a7
commit 3de09fbe74
14 changed files with 843 additions and 128 deletions

View File

@@ -85,6 +85,84 @@ describe("prepareCliBundleMcpConfig", () => {
}
});
it("merges loopback overlay config with bundle MCP servers", async () => {
const env = captureEnv(["HOME"]);
try {
const homeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-");
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-");
process.env.HOME = homeDir;
await createBundleProbePlugin(homeDir);
const config: OpenClawConfig = {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config,
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;
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; headers?: Record<string, string> }>;
};
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer ${OPENCLAW_MCP_TOKEN}");
await prepared.cleanup?.();
} finally {
env.restore();
}
});
it("preserves extra env values alongside generated MCP config", async () => {
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-env-");
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config: {},
env: {
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
},
});
expect(prepared.env).toEqual({
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
});
await prepared.cleanup?.();
});
it("leaves args untouched when bundle MCP is disabled", async () => {
const prepared = await prepareCliBundleMcpConfig({
enabled: false,

View File

@@ -15,6 +15,7 @@ type PreparedCliBundleMcpConfig = {
backend: CliBackendConfig;
cleanup?: () => Promise<void>;
mcpConfigHash?: string;
env?: Record<string, string>;
};
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
@@ -69,10 +70,12 @@ export async function prepareCliBundleMcpConfig(params: {
backend: CliBackendConfig;
workspaceDir: string;
config?: OpenClawConfig;
additionalConfig?: BundleMcpConfig;
env?: Record<string, string>;
warn?: (message: string) => void;
}): Promise<PreparedCliBundleMcpConfig> {
if (!params.enabled) {
return { backend: params.backend };
return { backend: params.backend, env: params.env };
}
const existingMcpConfigPath =
@@ -97,6 +100,9 @@ export async function prepareCliBundleMcpConfig(params: {
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig;
if (params.additionalConfig) {
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
}
// Always pass an explicit strict MCP config for background claude-cli runs.
// Otherwise Claude may inherit ambient user/global MCP servers (for example
@@ -116,6 +122,7 @@ export async function prepareCliBundleMcpConfig(params: {
),
},
mcpConfigHash: crypto.createHash("sha256").update(serializedConfig).digest("hex"),
env: params.env,
cleanup: async () => {
await fs.rm(tempDir, { recursive: true, force: true });
},

View File

@@ -176,6 +176,7 @@ export async function executePreparedCliRun(
for (const key of backend.clearEnv ?? []) {
delete next[key];
}
Object.assign(next, context.preparedBackend.env);
return next;
})();
const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({

View File

@@ -1,4 +1,8 @@
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
} from "../../gateway/mcp-http.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import {
buildBootstrapInjectionStats,
@@ -28,6 +32,8 @@ import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js";
const prepareDeps = {
makeBootstrapWarn: makeBootstrapWarnImpl,
resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl,
getActiveMcpLoopbackRuntime,
createMcpLoopbackServerConfig,
};
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
@@ -59,30 +65,10 @@ export async function prepareCliRunContext(
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
const preparedBackend = await prepareCliBundleMcpConfig({
enabled: backendResolved.bundleMcp,
backend: backendResolved.config,
workspaceDir,
config: params.config,
warn: (message) => cliBackendLog.warn(message),
});
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
const extraSystemPromptHash = hashCliSessionText(extraSystemPrompt);
const reusableCliSession = resolveCliSessionReuse({
binding:
params.cliSessionBinding ??
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
authProfileId: params.authProfileId,
extraSystemPromptHash,
mcpConfigHash: preparedBackend.mcpConfigHash,
});
if (reusableCliSession.invalidatedReason) {
cliBackendLog.info(
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
);
}
const modelId = (params.model ?? "default").trim() || "default";
const normalizedModel = normalizeCliModel(modelId, preparedBackend.backend);
const normalizedModel = normalizeCliModel(modelId, backendResolved.config);
const modelDisplay = `${params.provider}/${modelId}`;
const sessionLabel = params.sessionKey ?? params.sessionId;
@@ -118,6 +104,40 @@ export async function prepareCliRunContext(
config: params.config,
agentId: params.agentId,
});
const mcpLoopbackRuntime =
backendResolved.id === "claude-cli" ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined;
const preparedBackend = await prepareCliBundleMcpConfig({
enabled: backendResolved.bundleMcp,
backend: backendResolved.config,
workspaceDir,
config: params.config,
additionalConfig: mcpLoopbackRuntime
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
: undefined,
env: mcpLoopbackRuntime
? {
OPENCLAW_MCP_TOKEN: mcpLoopbackRuntime.token,
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
}
: undefined,
warn: (message) => cliBackendLog.warn(message),
});
const reusableCliSession = resolveCliSessionReuse({
binding:
params.cliSessionBinding ??
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
authProfileId: params.authProfileId,
extraSystemPromptHash,
mcpConfigHash: preparedBackend.mcpConfigHash,
});
if (reusableCliSession.invalidatedReason) {
cliBackendLog.info(
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
);
}
const heartbeatPrompt =
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)

View File

@@ -30,12 +30,15 @@ export type RunCliAgentParams = {
bootstrapPromptWarningSignature?: string;
images?: ImageContent[];
imageOrder?: PromptImageOrderEntry[];
messageProvider?: string;
agentAccountId?: string;
};
export type CliPreparedBackend = {
backend: CliBackendConfig;
cleanup?: () => Promise<void>;
mcpConfigHash?: string;
env?: Record<string, string>;
};
export type CliReusableSession = {