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

@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
### Fixes

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 = {

View File

@ -371,6 +371,8 @@ export function runAgentAttempt(params: {
images: params.isFallbackRetry ? undefined : params.opts.images,
imageOrder: params.isFallbackRetry ? undefined : params.opts.imageOrder,
streamParams: params.opts.streamParams,
messageProvider: params.messageChannel,
agentAccountId: params.runContext.accountId,
});
return runCliWithSession(cliSessionBinding?.sessionId).catch(async (err) => {
if (

View File

@ -558,6 +558,8 @@ export async function runAgentTurnWithFallback(params: {
],
images: params.opts?.images,
imageOrder: params.opts?.imageOrder,
messageProvider: params.followupRun.run.messageProvider,
agentAccountId: params.followupRun.run.agentAccountId,
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,

View File

@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
const resolveGatewayScopedToolsMock = vi.hoisted(() =>
vi.fn(() => ({
agentId: "main",
tools: [
{
name: "message",
description: "send a message",
parameters: { type: "object", properties: {} },
execute: async () => ({
content: [{ type: "text", text: "ok" }],
}),
},
],
})),
);
vi.mock("../config/config.js", () => ({
loadConfig: () => ({ session: { mainKey: "main" } }),
}));
vi.mock("../config/sessions.js", () => ({
resolveMainSessionKey: () => "agent:main:main",
}));
vi.mock("./tool-resolution.js", () => ({
resolveGatewayScopedTools: (...args: Parameters<typeof resolveGatewayScopedToolsMock>) =>
resolveGatewayScopedToolsMock(...args),
}));
import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
startMcpLoopbackServer,
} from "./mcp-http.js";
let server: Awaited<ReturnType<typeof startMcpLoopbackServer>> | undefined;
async function sendRaw(params: {
port: number;
token?: string;
headers?: Record<string, string>;
body?: string;
}) {
return await fetch(`http://127.0.0.1:${params.port}/mcp`, {
method: "POST",
headers: {
...(params.token ? { authorization: `Bearer ${params.token}` } : {}),
...params.headers,
},
body: params.body,
});
}
beforeEach(() => {
resolveGatewayScopedToolsMock.mockClear();
resolveGatewayScopedToolsMock.mockReturnValue({
agentId: "main",
tools: [
{
name: "message",
description: "send a message",
parameters: { type: "object", properties: {} },
execute: async () => ({
content: [{ type: "text", text: "ok" }],
}),
},
],
});
});
afterEach(async () => {
await server?.close();
server = undefined;
});
describe("mcp loopback server", () => {
it("passes session, account, and message channel headers into shared tool resolution", async () => {
const port = await getFreePortBlockWithPermissionFallback({
offsets: [0],
fallbackBase: 53_000,
});
server = await startMcpLoopbackServer(port);
const runtime = getActiveMcpLoopbackRuntime();
const response = await sendRaw({
port: server.port,
token: runtime?.token,
headers: {
"content-type": "application/json",
"x-session-key": "agent:main:telegram:group:chat123",
"x-openclaw-account-id": "work",
"x-openclaw-message-channel": "telegram",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
expect(response.status).toBe(200);
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:telegram:group:chat123",
accountId: "work",
messageProvider: "telegram",
}),
);
});
it("tracks the active runtime only while the server is running", async () => {
server = await startMcpLoopbackServer(0);
const active = getActiveMcpLoopbackRuntime();
expect(active?.port).toBe(server.port);
expect(active?.token).toMatch(/^[0-9a-f]{64}$/);
await server.close();
server = undefined;
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
});
it("returns 401 when the bearer token is missing", async () => {
server = await startMcpLoopbackServer(0);
const response = await sendRaw({
port: server.port,
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
expect(response.status).toBe(401);
});
it("returns 415 when the content type is not JSON", async () => {
server = await startMcpLoopbackServer(0);
const runtime = getActiveMcpLoopbackRuntime();
const response = await sendRaw({
port: server.port,
token: runtime?.token,
headers: { "content-type": "text/plain" },
body: "{}",
});
expect(response.status).toBe(415);
});
});
describe("createMcpLoopbackServerConfig", () => {
it("builds a server entry with env-driven headers", () => {
const config = createMcpLoopbackServerConfig(23119) as {
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
};
expect(config.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(config.mcpServers?.openclaw?.headers?.Authorization).toBe(
"Bearer ${OPENCLAW_MCP_TOKEN}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
"${OPENCLAW_MCP_MESSAGE_CHANNEL}",
);
});
});

391
src/gateway/mcp-http.ts Normal file
View File

@ -0,0 +1,391 @@
import crypto from "node:crypto";
import { createServer as createHttpServer } from "node:http";
import type { IncomingMessage } from "node:http";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { logDebug, logWarn } from "../logger.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { getHeader } from "./http-utils.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
const SERVER_NAME = "openclaw";
const SERVER_VERSION = "0.1.0";
const MAX_BODY_BYTES = 1_048_576;
const TOOL_CACHE_TTL_MS = 30_000;
const SUPPORTED_PROTOCOL_VERSIONS = ["2025-03-26", "2024-11-05"] as const;
const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]);
type JsonRpcId = string | number | null | undefined;
type JsonRpcRequest = {
jsonrpc: "2.0";
id?: JsonRpcId;
method: string;
params?: Record<string, unknown>;
};
type McpLoopbackRuntime = {
port: number;
token: string;
};
let activeRuntime: McpLoopbackRuntime | undefined;
function jsonRpcResult(id: JsonRpcId, result: unknown) {
return { jsonrpc: "2.0" as const, id: id ?? null, result };
}
function jsonRpcError(id: JsonRpcId, code: number, message: string) {
return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } };
}
function flattenUnionSchema(raw: Record<string, unknown>): Record<string, unknown> {
const variants = (raw.anyOf ?? raw.oneOf) as Record<string, unknown>[] | undefined;
if (!Array.isArray(variants) || variants.length === 0) {
return raw;
}
const mergedProps: Record<string, unknown> = {};
const requiredSets: Set<string>[] = [];
for (const variant of variants) {
const props = variant.properties as Record<string, unknown> | undefined;
if (props) {
for (const [key, schema] of Object.entries(props)) {
if (!(key in mergedProps)) {
mergedProps[key] = schema;
continue;
}
const existing = mergedProps[key] as Record<string, unknown>;
const incoming = schema as Record<string, unknown>;
if (Array.isArray(existing.enum) && Array.isArray(incoming.enum)) {
mergedProps[key] = {
...existing,
enum: [...new Set([...(existing.enum as unknown[]), ...(incoming.enum as unknown[])])],
};
continue;
}
if ("const" in existing && "const" in incoming && existing.const !== incoming.const) {
const merged: Record<string, unknown> = {
...existing,
enum: [existing.const, incoming.const],
};
delete merged.const;
mergedProps[key] = merged;
continue;
}
logWarn(
`mcp loopback: conflicting schema definitions for "${key}", keeping the first variant`,
);
}
}
requiredSets.push(
new Set(Array.isArray(variant.required) ? (variant.required as string[]) : []),
);
}
const required =
requiredSets.length > 0
? [
...requiredSets.reduce(
(left, right) => new Set([...left].filter((key) => right.has(key))),
),
]
: [];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = raw;
return { ...rest, type: "object", properties: mergedProps, required };
}
function buildToolSchema(tools: ReturnType<typeof resolveGatewayScopedTools>["tools"]) {
return tools.map((tool) => {
let raw =
tool.parameters && typeof tool.parameters === "object"
? { ...(tool.parameters as Record<string, unknown>) }
: {};
if (raw.anyOf || raw.oneOf) {
raw = flattenUnionSchema(raw);
}
if (raw.type !== "object") {
raw.type = "object";
if (!raw.properties) {
raw.properties = {};
}
}
return {
name: tool.name,
description: tool.description,
inputSchema: raw,
};
});
}
function resolveScopedSessionKey(
cfg: ReturnType<typeof loadConfig>,
rawSessionKey: string | undefined,
): string {
const trimmed = rawSessionKey?.trim();
return !trimmed || trimmed === "main" ? resolveMainSessionKey(cfg) : trimmed;
}
async function readBody(req: IncomingMessage): Promise<string> {
return await new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let received = 0;
req.on("data", (chunk: Buffer) => {
received += chunk.length;
if (received > MAX_BODY_BYTES) {
req.destroy();
reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
export function getActiveMcpLoopbackRuntime(): McpLoopbackRuntime | undefined {
return activeRuntime ? { ...activeRuntime } : undefined;
}
export function createMcpLoopbackServerConfig(port: number) {
return {
mcpServers: {
openclaw: {
type: "http",
url: `http://127.0.0.1:${port}/mcp`,
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
},
},
},
};
}
async function handleJsonRpc(params: {
message: JsonRpcRequest;
tools: ReturnType<typeof resolveGatewayScopedTools>["tools"];
toolSchema: ReturnType<typeof buildToolSchema>;
}): Promise<object | null> {
const { id, method, params: methodParams } = params.message;
switch (method) {
case "initialize": {
const clientVersion = (methodParams?.protocolVersion as string) ?? "";
const negotiated =
SUPPORTED_PROTOCOL_VERSIONS.find((version) => version === clientVersion) ??
SUPPORTED_PROTOCOL_VERSIONS[0];
return jsonRpcResult(id, {
protocolVersion: negotiated,
capabilities: { tools: {} },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
});
}
case "notifications/initialized":
case "notifications/cancelled":
return null;
case "tools/list":
return jsonRpcResult(id, { tools: params.toolSchema });
case "tools/call": {
const toolName = methodParams?.name as string;
const toolArgs = (methodParams?.arguments ?? {}) as Record<string, unknown>;
const tool = params.tools.find((candidate) => candidate.name === toolName);
if (!tool) {
return jsonRpcResult(id, {
content: [{ type: "text", text: `Tool not available: ${toolName}` }],
isError: true,
});
}
const toolCallId = `mcp-${crypto.randomUUID()}`;
try {
// oxlint-disable-next-line typescript/no-explicit-any
const result = await (tool as any).execute(toolCallId, toolArgs);
const content =
result?.content && Array.isArray(result.content)
? result.content.map((block: { type?: string; text?: string }) => ({
type: (block.type ?? "text") as "text",
text: block.text ?? (typeof block === "string" ? block : JSON.stringify(block)),
}))
: [
{
type: "text",
text: typeof result === "string" ? result : JSON.stringify(result),
},
];
return jsonRpcResult(id, { content, isError: false });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonRpcResult(id, {
content: [{ type: "text", text: message || "tool execution failed" }],
isError: true,
});
}
}
default:
return jsonRpcError(id, -32601, `Method not found: ${method}`);
}
}
export async function startMcpLoopbackServer(port = 0): Promise<{
port: number;
close: () => Promise<void>;
}> {
const token = crypto.randomBytes(32).toString("hex");
const toolCache = new Map<
string,
{
tools: ReturnType<typeof resolveGatewayScopedTools>["tools"];
toolSchema: ReturnType<typeof buildToolSchema>;
configRef: ReturnType<typeof loadConfig>;
time: number;
}
>();
const httpServer = createHttpServer((req, res) => {
let url: URL;
try {
url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "bad_request" }));
return;
}
if (req.method === "GET" && url.pathname.startsWith("/.well-known/")) {
res.writeHead(404);
res.end();
return;
}
if (url.pathname !== "/mcp") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
return;
}
if (req.method !== "POST") {
res.writeHead(405, { Allow: "POST" });
res.end();
return;
}
const authHeader = getHeader(req, "authorization") ?? "";
if (authHeader !== `Bearer ${token}`) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "unauthorized" }));
return;
}
const contentType = getHeader(req, "content-type") ?? "";
if (!contentType.startsWith("application/json")) {
res.writeHead(415, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "unsupported_media_type" }));
return;
}
void (async () => {
try {
const body = await readBody(req);
const parsed: JsonRpcRequest | JsonRpcRequest[] = JSON.parse(body);
const cfg = loadConfig();
const sessionKey = resolveScopedSessionKey(cfg, getHeader(req, "x-session-key"));
const messageProvider =
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined;
const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined;
const cacheKey = [sessionKey, messageProvider ?? "", accountId ?? ""].join("\u0000");
const now = Date.now();
const cached = toolCache.get(cacheKey);
const scopedTools =
cached && cached.configRef === cfg && now - cached.time < TOOL_CACHE_TTL_MS
? cached
: (() => {
const next = resolveGatewayScopedTools({
cfg,
sessionKey,
messageProvider,
accountId,
excludeToolNames: NATIVE_TOOL_EXCLUDE,
});
const nextEntry = {
tools: next.tools,
toolSchema: buildToolSchema(next.tools),
configRef: cfg,
time: now,
};
toolCache.set(cacheKey, nextEntry);
for (const [key, entry] of toolCache) {
if (now - entry.time >= TOOL_CACHE_TTL_MS) {
toolCache.delete(key);
}
}
return nextEntry;
})();
const messages = Array.isArray(parsed) ? parsed : [parsed];
const responses: object[] = [];
for (const message of messages) {
const response = await handleJsonRpc({
message,
tools: scopedTools.tools,
toolSchema: scopedTools.toolSchema,
});
if (response !== null) {
responses.push(response);
}
}
if (responses.length === 0) {
res.writeHead(202);
res.end();
return;
}
const payload = Array.isArray(parsed)
? JSON.stringify(responses)
: JSON.stringify(responses[0]);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(payload);
} catch (error) {
logWarn(
`mcp loopback: request handling failed: ${error instanceof Error ? error.message : String(error)}`,
);
if (!res.headersSent) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error")));
}
}
})();
});
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => {
httpServer.removeListener("error", reject);
resolve();
});
});
const address = httpServer.address();
if (!address || typeof address === "string") {
throw new Error("mcp loopback did not bind to a TCP port");
}
activeRuntime = { port: address.port, token };
logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`);
return {
port: address.port,
close: () =>
new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (!error && activeRuntime?.token === token) {
activeRuntime = undefined;
}
if (error) {
reject(error);
return;
}
resolve();
});
}),
};
}

View File

@ -89,6 +89,7 @@ import {
type GatewayUpdateAvailableEventPayload,
} from "./events.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { startMcpLoopbackServer } from "./mcp-http.js";
import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
import { NodeRegistry } from "./node-registry.js";
import { createChannelManager } from "./server-channels.js";
@ -783,6 +784,7 @@ export async function startGatewayServer(
let skillsChangeUnsub = () => {};
let channelHealthMonitor: ReturnType<typeof startChannelHealthMonitor> | null = null;
let stopModelPricingRefresh = () => {};
let mcpServer: { port: number; close: () => Promise<void> } | undefined;
let configReloader: { stop: () => Promise<void> } = { stop: async () => {} };
const closeOnStartupFailure = async () => {
if (diagnosticsEnabled) {
@ -798,6 +800,7 @@ export async function startGatewayServer(
stopModelPricingRefresh();
channelHealthMonitor?.stop();
clearSecretsRuntimeSnapshot();
await mcpServer?.close().catch(() => {});
await createGatewayCloseHandler({
bonjourStop,
tailscaleCleanup,
@ -864,6 +867,13 @@ export async function startGatewayServer(
let transcriptUnsub: (() => void) | null = null;
let lifecycleUnsub: (() => void) | null = null;
try {
try {
mcpServer = await startMcpLoopbackServer(0);
log.info(`MCP loopback server listening on http://127.0.0.1:${mcpServer.port}/mcp`);
} catch (error) {
log.warn(`MCP loopback server failed to start: ${String(error)}`);
}
if (!minimalTestGateway) {
const machineDisplayName = await getMachineDisplayName();
const discovery = await startGatewayDiscovery({
@ -1542,6 +1552,7 @@ export async function startGatewayServer(
stopModelPricingRefresh();
channelHealthMonitor?.stop();
clearSecretsRuntimeSnapshot();
await mcpServer?.close().catch(() => {});
await close(opts);
},
};

View File

@ -0,0 +1,127 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { createOpenClawTools } from "../agents/openclaw-tools.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
} from "../agents/pi-tools.policy.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
} from "../agents/tool-policy-pipeline.js";
import {
collectExplicitAllowlist,
mergeAlsoAllowPolicy,
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
import { loadConfig } from "../config/config.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
export function resolveGatewayScopedTools(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey: string;
messageProvider?: string;
accountId?: string;
agentTo?: string;
agentThreadId?: string;
allowGatewaySubagentBinding?: boolean;
allowMediaInvokeCommands?: boolean;
excludeToolNames?: Iterable<string>;
}) {
const {
agentId,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({ config: params.cfg, sessionKey: params.sessionKey });
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const groupPolicy = resolveGroupToolPolicy({
config: params.cfg,
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
accountId: params.accountId ?? null,
});
const subagentPolicy = isSubagentSessionKey(params.sessionKey)
? resolveSubagentToolPolicy(params.cfg)
: undefined;
const workspaceDir = resolveAgentWorkspaceDir(
params.cfg,
agentId ?? resolveDefaultAgentId(params.cfg),
);
const allTools = createOpenClawTools({
agentSessionKey: params.sessionKey,
agentChannel: params.messageProvider ?? undefined,
agentAccountId: params.accountId,
agentTo: params.agentTo,
agentThreadId: params.agentThreadId,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
allowMediaInvokeCommands: params.allowMediaInvokeCommands,
config: params.cfg,
workspaceDir,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
]),
});
const policyFiltered = applyToolPolicyPipeline({
// oxlint-disable-next-line typescript/no-explicit-any
tools: allTools as any,
// oxlint-disable-next-line typescript/no-explicit-any
toolMeta: (tool) => getPluginToolMeta(tool as any),
warn: logWarn,
steps: [
...buildDefaultToolPolicyPipelineSteps({
profilePolicy: profilePolicyWithAlsoAllow,
profile,
profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
providerProfile,
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
agentId,
}),
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
const gatewayToolsCfg = params.cfg.gateway?.tools;
const defaultGatewayDeny = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
(name) => !gatewayToolsCfg?.allow?.includes(name),
);
const gatewayDenySet = new Set([
...defaultGatewayDeny,
...(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []),
...(params.excludeToolNames ? Array.from(params.excludeToolNames) : []),
]);
return {
agentId,
tools: policyFiltered.filter((tool) => !gatewayDenySet.has(tool.name)),
};
}

View File

@ -736,10 +736,16 @@ describe("POST /tools/invoke", () => {
it("requires operator.write scope for HTTP tool invocation", async () => {
allowAgentsListForMain();
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({
ok: true,
method: "trusted-proxy",
});
const res = await invokeTool({
port: sharedPort,
headers: {},
headers: {
"x-openclaw-scopes": "",
},
tool: "agents_list",
sessionKey: "main",
});

View File

@ -1,31 +1,12 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { createOpenClawTools } from "../agents/openclaw-tools.js";
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
} from "../agents/pi-tools.policy.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
} from "../agents/tool-policy-pipeline.js";
import {
applyOwnerOnlyToolPolicy,
collectExplicitAllowlist,
mergeAlsoAllowPolicy,
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
import { ToolInputError } from "../agents/tools/common.js";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
@ -42,6 +23,7 @@ import {
resolveOpenAiCompatibleHttpSenderIsOwner,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
@ -151,7 +133,14 @@ export async function handleToolsInvokeHttpRequest(
rateLimiter?: AuthRateLimiter;
},
): Promise<boolean> {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
let url: URL;
try {
url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "bad_request", message: "Invalid request URL" }));
return true;
}
if (url.pathname !== "/tools/invoke") {
return false;
}
@ -238,100 +227,20 @@ export async function handleToolsInvokeHttpRequest(
const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined;
const agentTo = getHeader(req, "x-openclaw-message-to")?.trim() || undefined;
const agentThreadId = getHeader(req, "x-openclaw-thread-id")?.trim() || undefined;
const {
agentId,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const groupPolicy = resolveGroupToolPolicy({
config: cfg,
const { agentId, tools } = resolveGatewayScopedTools({
cfg,
sessionKey,
messageProvider: messageChannel ?? undefined,
accountId: accountId ?? null,
});
const subagentPolicy = isSubagentSessionKey(sessionKey)
? resolveSubagentToolPolicy(cfg)
: undefined;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId ?? resolveDefaultAgentId(cfg));
// Build tool list (core + plugin tools).
const allTools = createOpenClawTools({
agentSessionKey: sessionKey,
agentChannel: messageChannel ?? undefined,
agentAccountId: accountId,
accountId,
agentTo,
agentThreadId,
allowGatewaySubagentBinding: true,
// HTTP callers consume tool output directly; preserve raw media invoke payloads.
allowMediaInvokeCommands: true,
config: cfg,
workspaceDir,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
]),
});
const subagentFiltered = applyToolPolicyPipeline({
// oxlint-disable-next-line typescript/no-explicit-any
tools: allTools as any,
// oxlint-disable-next-line typescript/no-explicit-any
toolMeta: (tool) => getPluginToolMeta(tool as any),
warn: logWarn,
steps: [
...buildDefaultToolPolicyPipelineSteps({
profilePolicy: profilePolicyWithAlsoAllow,
profile,
profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
providerProfile,
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
agentId,
}),
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
// Gateway HTTP-specific deny list — applies to ALL sessions via HTTP.
const gatewayToolsCfg = cfg.gateway?.tools;
const defaultGatewayDeny: string[] = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
(name) => !gatewayToolsCfg?.allow?.includes(name),
);
const gatewayDenyNames = defaultGatewayDeny.concat(
Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : [],
);
const gatewayDenySet = new Set(gatewayDenyNames);
// Owner semantics intentionally follow the same shared-secret HTTP contract
// on this direct tool surface; SECURITY.md documents this as designed-as-is.
const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth);
const ownerFiltered = applyOwnerOnlyToolPolicy(subagentFiltered, senderIsOwner);
const gatewayFiltered = ownerFiltered.filter((t) => !gatewayDenySet.has(t.name));
const gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
const tool = gatewayFiltered.find((t) => t.name === toolName);
if (!tool) {