From 3de09fbe7427611e0ef12972a07a4b7b8bcf9c39 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 15:14:22 +0900 Subject: [PATCH] fix: restore claude cli loopback mcp bridge (#35676) (thanks @mylukin) --- CHANGELOG.md | 1 + src/agents/cli-runner/bundle-mcp.test.ts | 78 ++++ src/agents/cli-runner/bundle-mcp.ts | 9 +- src/agents/cli-runner/execute.ts | 1 + src/agents/cli-runner/prepare.ts | 62 ++- src/agents/cli-runner/types.ts | 3 + src/agents/command/attempt-execution.ts | 2 + .../reply/agent-runner-execution.ts | 2 + src/gateway/mcp-http.test.ts | 157 +++++++ src/gateway/mcp-http.ts | 391 ++++++++++++++++++ src/gateway/server.impl.ts | 11 + src/gateway/tool-resolution.ts | 127 ++++++ src/gateway/tools-invoke-http.test.ts | 8 +- src/gateway/tools-invoke-http.ts | 119 +----- 14 files changed, 843 insertions(+), 128 deletions(-) create mode 100644 src/gateway/mcp-http.test.ts create mode 100644 src/gateway/mcp-http.ts create mode 100644 src/gateway/tool-resolution.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8e661b380..4a30bff4d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 4760e2f66e4..f02ffc28ab8 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -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 }>; + }; + 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, diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 3d450eabe9c..3494c6a76a7 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -15,6 +15,7 @@ type PreparedCliBundleMcpConfig = { backend: CliBackendConfig; cleanup?: () => Promise; mcpConfigHash?: string; + env?: Record; }; async function readExternalMcpConfig(configPath: string): Promise { @@ -69,10 +70,12 @@ export async function prepareCliBundleMcpConfig(params: { backend: CliBackendConfig; workspaceDir: string; config?: OpenClawConfig; + additionalConfig?: BundleMcpConfig; + env?: Record; warn?: (message: string) => void; }): Promise { 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 }); }, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index e2caf37c9eb..34c2618a848 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -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({ diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 875b36b5f49..6af6b886ab3 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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): 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) diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index b138206c66f..d40a9c6f1f4 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -30,12 +30,15 @@ export type RunCliAgentParams = { bootstrapPromptWarningSignature?: string; images?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; + messageProvider?: string; + agentAccountId?: string; }; export type CliPreparedBackend = { backend: CliBackendConfig; cleanup?: () => Promise; mcpConfigHash?: string; + env?: Record; }; export type CliReusableSession = { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 875110e1d91..825dd4bed79 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -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 ( diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 5beab4cbeee..ebe93a98624 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts new file mode 100644 index 00000000000..31dbe77fc06 --- /dev/null +++ b/src/gateway/mcp-http.test.ts @@ -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) => + resolveGatewayScopedToolsMock(...args), +})); + +import { + createMcpLoopbackServerConfig, + getActiveMcpLoopbackRuntime, + startMcpLoopbackServer, +} from "./mcp-http.js"; + +let server: Awaited> | undefined; + +async function sendRaw(params: { + port: number; + token?: string; + headers?: Record; + 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 }>; + }; + 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}", + ); + }); +}); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts new file mode 100644 index 00000000000..8a4e4e357d7 --- /dev/null +++ b/src/gateway/mcp-http.ts @@ -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; +}; + +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): Record { + const variants = (raw.anyOf ?? raw.oneOf) as Record[] | undefined; + if (!Array.isArray(variants) || variants.length === 0) { + return raw; + } + const mergedProps: Record = {}; + const requiredSets: Set[] = []; + for (const variant of variants) { + const props = variant.properties as Record | 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; + const incoming = schema as Record; + 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 = { + ...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["tools"]) { + return tools.map((tool) => { + let raw = + tool.parameters && typeof tool.parameters === "object" + ? { ...(tool.parameters as Record) } + : {}; + 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, + rawSessionKey: string | undefined, +): string { + const trimmed = rawSessionKey?.trim(); + return !trimmed || trimmed === "main" ? resolveMainSessionKey(cfg) : trimmed; +} + +async function readBody(req: IncomingMessage): Promise { + 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["tools"]; + toolSchema: ReturnType; +}): Promise { + 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; + 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; +}> { + const token = crypto.randomBytes(32).toString("hex"); + const toolCache = new Map< + string, + { + tools: ReturnType["tools"]; + toolSchema: ReturnType; + configRef: ReturnType; + 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((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((resolve, reject) => { + httpServer.close((error) => { + if (!error && activeRuntime?.token === token) { + activeRuntime = undefined; + } + if (error) { + reject(error); + return; + } + resolve(); + }); + }), + }; +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 9be6a449fa8..9fbee0120bd 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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 | null = null; let stopModelPricingRefresh = () => {}; + let mcpServer: { port: number; close: () => Promise } | undefined; let configReloader: { stop: () => Promise } = { 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); }, }; diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts new file mode 100644 index 00000000000..acb85dc518a --- /dev/null +++ b/src/gateway/tool-resolution.ts @@ -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; + sessionKey: string; + messageProvider?: string; + accountId?: string; + agentTo?: string; + agentThreadId?: string; + allowGatewaySubagentBinding?: boolean; + allowMediaInvokeCommands?: boolean; + excludeToolNames?: Iterable; +}) { + 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)), + }; +} diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index a275871364c..d1715866d60 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -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", }); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 4ade106c199..de263849039 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -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 { - 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) {