Skip to content

Commit 3de09fb

Browse files
committed
fix: restore claude cli loopback mcp bridge (#35676) (thanks @mylukin)
1 parent c243530 commit 3de09fb

14 files changed

Lines changed: 843 additions & 128 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828
- Agents/system prompts: add an internal cache-prefix boundary across Anthropic-family, OpenAI-family, Google, and CLI transport shaping so stable system-prompt prefixes stay reusable without leaking internal cache markers to provider payloads. (#59054) Thanks @coletebou and @vincentkoc.
2929
- Docs/memory: add a dedicated Dreaming concept page, expand Memory overview with the Dreaming model, and link Dreaming from further reading to document the experimental opt-in consolidation workflow. Thanks @vignesh07.
3030
- Agents/cache prefixes: route compaction, OpenAI WebSocket HTTP fallback, and later-turn embedded session reuse through the same cache-safe prompt shaping path so Anthropic-family and OpenAI-family requests keep stable prompt bytes across follow-up turns and fallback transport changes. (#60691) Thanks @vincentkoc.
31+
- Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge that reuses gateway tool policy, honors session/account/channel scoping, and only advertises the bridge when the local runtime is actually live. (#35676) Thanks @mylukin.
3132

3233
### Fixes
3334

src/agents/cli-runner/bundle-mcp.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,84 @@ describe("prepareCliBundleMcpConfig", () => {
8585
}
8686
});
8787

88+
it("merges loopback overlay config with bundle MCP servers", async () => {
89+
const env = captureEnv(["HOME"]);
90+
try {
91+
const homeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-");
92+
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-");
93+
process.env.HOME = homeDir;
94+
95+
await createBundleProbePlugin(homeDir);
96+
97+
const config: OpenClawConfig = {
98+
plugins: {
99+
entries: {
100+
"bundle-probe": { enabled: true },
101+
},
102+
},
103+
};
104+
105+
const prepared = await prepareCliBundleMcpConfig({
106+
enabled: true,
107+
backend: {
108+
command: "node",
109+
args: ["./fake-claude.mjs"],
110+
},
111+
workspaceDir,
112+
config,
113+
additionalConfig: {
114+
mcpServers: {
115+
openclaw: {
116+
type: "http",
117+
url: "http://127.0.0.1:23119/mcp",
118+
headers: {
119+
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
120+
},
121+
},
122+
},
123+
},
124+
});
125+
126+
const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1;
127+
const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1];
128+
const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as {
129+
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
130+
};
131+
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
132+
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
133+
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer ${OPENCLAW_MCP_TOKEN}");
134+
135+
await prepared.cleanup?.();
136+
} finally {
137+
env.restore();
138+
}
139+
});
140+
141+
it("preserves extra env values alongside generated MCP config", async () => {
142+
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-env-");
143+
144+
const prepared = await prepareCliBundleMcpConfig({
145+
enabled: true,
146+
backend: {
147+
command: "node",
148+
args: ["./fake-claude.mjs"],
149+
},
150+
workspaceDir,
151+
config: {},
152+
env: {
153+
OPENCLAW_MCP_TOKEN: "loopback-token-123",
154+
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
155+
},
156+
});
157+
158+
expect(prepared.env).toEqual({
159+
OPENCLAW_MCP_TOKEN: "loopback-token-123",
160+
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
161+
});
162+
163+
await prepared.cleanup?.();
164+
});
165+
88166
it("leaves args untouched when bundle MCP is disabled", async () => {
89167
const prepared = await prepareCliBundleMcpConfig({
90168
enabled: false,

src/agents/cli-runner/bundle-mcp.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type PreparedCliBundleMcpConfig = {
1515
backend: CliBackendConfig;
1616
cleanup?: () => Promise<void>;
1717
mcpConfigHash?: string;
18+
env?: Record<string, string>;
1819
};
1920

2021
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
@@ -69,10 +70,12 @@ export async function prepareCliBundleMcpConfig(params: {
6970
backend: CliBackendConfig;
7071
workspaceDir: string;
7172
config?: OpenClawConfig;
73+
additionalConfig?: BundleMcpConfig;
74+
env?: Record<string, string>;
7275
warn?: (message: string) => void;
7376
}): Promise<PreparedCliBundleMcpConfig> {
7477
if (!params.enabled) {
75-
return { backend: params.backend };
78+
return { backend: params.backend, env: params.env };
7679
}
7780

7881
const existingMcpConfigPath =
@@ -97,6 +100,9 @@ export async function prepareCliBundleMcpConfig(params: {
97100
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
98101
}
99102
mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig;
103+
if (params.additionalConfig) {
104+
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
105+
}
100106

101107
// Always pass an explicit strict MCP config for background claude-cli runs.
102108
// Otherwise Claude may inherit ambient user/global MCP servers (for example
@@ -116,6 +122,7 @@ export async function prepareCliBundleMcpConfig(params: {
116122
),
117123
},
118124
mcpConfigHash: crypto.createHash("sha256").update(serializedConfig).digest("hex"),
125+
env: params.env,
119126
cleanup: async () => {
120127
await fs.rm(tempDir, { recursive: true, force: true });
121128
},

src/agents/cli-runner/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export async function executePreparedCliRun(
176176
for (const key of backend.clearEnv ?? []) {
177177
delete next[key];
178178
}
179+
Object.assign(next, context.preparedBackend.env);
179180
return next;
180181
})();
181182
const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({

src/agents/cli-runner/prepare.ts

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
2+
import {
3+
createMcpLoopbackServerConfig,
4+
getActiveMcpLoopbackRuntime,
5+
} from "../../gateway/mcp-http.js";
26
import { resolveSessionAgentIds } from "../agent-scope.js";
37
import {
48
buildBootstrapInjectionStats,
@@ -28,6 +32,8 @@ import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js";
2832
const prepareDeps = {
2933
makeBootstrapWarn: makeBootstrapWarnImpl,
3034
resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl,
35+
getActiveMcpLoopbackRuntime,
36+
createMcpLoopbackServerConfig,
3137
};
3238

3339
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
@@ -59,30 +65,10 @@ export async function prepareCliRunContext(
5965
if (!backendResolved) {
6066
throw new Error(`Unknown CLI backend: ${params.provider}`);
6167
}
62-
const preparedBackend = await prepareCliBundleMcpConfig({
63-
enabled: backendResolved.bundleMcp,
64-
backend: backendResolved.config,
65-
workspaceDir,
66-
config: params.config,
67-
warn: (message) => cliBackendLog.warn(message),
68-
});
6968
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
7069
const extraSystemPromptHash = hashCliSessionText(extraSystemPrompt);
71-
const reusableCliSession = resolveCliSessionReuse({
72-
binding:
73-
params.cliSessionBinding ??
74-
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
75-
authProfileId: params.authProfileId,
76-
extraSystemPromptHash,
77-
mcpConfigHash: preparedBackend.mcpConfigHash,
78-
});
79-
if (reusableCliSession.invalidatedReason) {
80-
cliBackendLog.info(
81-
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
82-
);
83-
}
8470
const modelId = (params.model ?? "default").trim() || "default";
85-
const normalizedModel = normalizeCliModel(modelId, preparedBackend.backend);
71+
const normalizedModel = normalizeCliModel(modelId, backendResolved.config);
8672
const modelDisplay = `${params.provider}/${modelId}`;
8773

8874
const sessionLabel = params.sessionKey ?? params.sessionId;
@@ -118,6 +104,40 @@ export async function prepareCliRunContext(
118104
config: params.config,
119105
agentId: params.agentId,
120106
});
107+
const mcpLoopbackRuntime =
108+
backendResolved.id === "claude-cli" ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined;
109+
const preparedBackend = await prepareCliBundleMcpConfig({
110+
enabled: backendResolved.bundleMcp,
111+
backend: backendResolved.config,
112+
workspaceDir,
113+
config: params.config,
114+
additionalConfig: mcpLoopbackRuntime
115+
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
116+
: undefined,
117+
env: mcpLoopbackRuntime
118+
? {
119+
OPENCLAW_MCP_TOKEN: mcpLoopbackRuntime.token,
120+
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
121+
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
122+
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
123+
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
124+
}
125+
: undefined,
126+
warn: (message) => cliBackendLog.warn(message),
127+
});
128+
const reusableCliSession = resolveCliSessionReuse({
129+
binding:
130+
params.cliSessionBinding ??
131+
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
132+
authProfileId: params.authProfileId,
133+
extraSystemPromptHash,
134+
mcpConfigHash: preparedBackend.mcpConfigHash,
135+
});
136+
if (reusableCliSession.invalidatedReason) {
137+
cliBackendLog.info(
138+
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
139+
);
140+
}
121141
const heartbeatPrompt =
122142
sessionAgentId === defaultAgentId
123143
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)

src/agents/cli-runner/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ export type RunCliAgentParams = {
3030
bootstrapPromptWarningSignature?: string;
3131
images?: ImageContent[];
3232
imageOrder?: PromptImageOrderEntry[];
33+
messageProvider?: string;
34+
agentAccountId?: string;
3335
};
3436

3537
export type CliPreparedBackend = {
3638
backend: CliBackendConfig;
3739
cleanup?: () => Promise<void>;
3840
mcpConfigHash?: string;
41+
env?: Record<string, string>;
3942
};
4043

4144
export type CliReusableSession = {

src/agents/command/attempt-execution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,8 @@ export function runAgentAttempt(params: {
371371
images: params.isFallbackRetry ? undefined : params.opts.images,
372372
imageOrder: params.isFallbackRetry ? undefined : params.opts.imageOrder,
373373
streamParams: params.opts.streamParams,
374+
messageProvider: params.messageChannel,
375+
agentAccountId: params.runContext.accountId,
374376
});
375377
return runCliWithSession(cliSessionBinding?.sessionId).catch(async (err) => {
376378
if (

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,8 @@ export async function runAgentTurnWithFallback(params: {
558558
],
559559
images: params.opts?.images,
560560
imageOrder: params.opts?.imageOrder,
561+
messageProvider: params.followupRun.run.messageProvider,
562+
agentAccountId: params.followupRun.run.agentAccountId,
561563
});
562564
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
563565
result.meta?.systemPromptReport,

0 commit comments

Comments
 (0)