From b5ba0771f9abacb6f09e563a9d7f3a6b19476ab7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 15:09:09 +0100 Subject: [PATCH] feat: carry gateway client capabilities through the CLI loopback backend (#102835) * test: shorten loopback token fixtures below secret-scanner thresholds * feat: carry gateway client capabilities through the CLI loopback backend CLI-backed model runs now transport the originating client's declared capabilities along the existing per-field loopback contract: RunCliAgentParams gains clientCaps, prepare emits OPENCLAW_MCP_CLIENT_CAPS, the loopback header template forwards x-openclaw-client-caps, the request context parses and normalizes it (grant-authenticated callers keep ignoring spoofable headers), and the loopback tool cache keys on a stable caps serialization so capless and capped requests never share tool lists. Capability-gated tools such as show_widget now work on CLI backends; the CLI session binding hash includes caps, consistent with messageProvider. Cron and command-attempt runs stay capless (fail closed). Fixes #102577 --- docs/tools/show-widget.md | 2 +- .../cli-runner/bundle-mcp.gemini.test.ts | 11 +++- src/agents/cli-runner/bundle-mcp.test.ts | 12 ++-- src/agents/cli-runner/prepare.test.ts | 6 ++ src/agents/cli-runner/prepare.ts | 2 + src/agents/cli-runner/types.ts | 2 + .../reply/agent-runner-execution.test.ts | 2 + .../reply/agent-runner-execution.ts | 1 + src/auto-reply/reply/followup-runner.test.ts | 2 + src/auto-reply/reply/followup-runner.ts | 1 + src/gateway/mcp-http.loopback-runtime.ts | 1 + src/gateway/mcp-http.request.ts | 12 ++++ src/gateway/mcp-http.runtime.ts | 4 ++ src/gateway/mcp-http.test.ts | 62 +++++++++++++++++++ src/gateway/mcp-http.ts | 1 + src/gateway/tool-resolution.exclude.test.ts | 14 +++++ 16 files changed, 124 insertions(+), 11 deletions(-) diff --git a/docs/tools/show-widget.md b/docs/tools/show-widget.md index a0ee6fd33f4..1fa2459e9f2 100644 --- a/docs/tools/show-widget.md +++ b/docs/tools/show-widget.md @@ -11,7 +11,7 @@ read_when: The tool is available only when the originating Gateway client declares the `inline-widgets` capability. The Control UI declares this capability automatically. Channel runs such as Telegram and WhatsApp do not receive `show_widget`. -Capability transport currently covers the embedded runner and the Codex app-server backend. CLI-backed model backends do not yet carry client capabilities, so capability-gated tools stay unavailable (fail closed) on those backends. +Capability transport covers embedded, Codex app-server, and CLI-backed model backends. Grant-authenticated MCP callers and direct HTTP tool-invoke callers remain fail closed because they do not declare client capabilities. ## Use the tool diff --git a/src/agents/cli-runner/bundle-mcp.gemini.test.ts b/src/agents/cli-runner/bundle-mcp.gemini.test.ts index af8f8d59f2e..6863118be3b 100644 --- a/src/agents/cli-runner/bundle-mcp.gemini.test.ts +++ b/src/agents/cli-runner/bundle-mcp.gemini.test.ts @@ -23,17 +23,19 @@ describe("prepareCliBundleMcpConfig gemini", () => { url: "http://127.0.0.1:23119/mcp", headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + "x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}", }, }, }, }, env: { - OPENCLAW_MCP_TOKEN: "loopback-token-123", + OPENCLAW_MCP_TOKEN: "lb-tk-123", + OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets", }, }); expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]); - expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("loopback-token-123"); + expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("lb-tk-123"); expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string"); // Gemini reads MCP servers from a generated system settings JSON file. const raw = JSON.parse( @@ -44,7 +46,10 @@ describe("prepareCliBundleMcpConfig gemini", () => { }; expect(raw.mcp?.allowed).toEqual(["openclaw"]); expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); - expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123"); + expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123"); + expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-client-caps"]).toBe( + "tool-events,inline-widgets", + ); await prepared.cleanup?.(); }); diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 418b5cd1fa1..ef8f9839870 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -289,7 +289,7 @@ describe("prepareCliBundleMcpConfig", () => { const prepared = await prepareBundleProbeCliConfig({ additionalConfig, env: { - OPENCLAW_MCP_TOKEN: "loopback-token-123", + OPENCLAW_MCP_TOKEN: "lb-tk-123", OPENCLAW_MCP_CLI_CAPTURE_KEY: "", }, }); @@ -307,7 +307,7 @@ describe("prepareCliBundleMcpConfig", () => { }; 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 loopback-token-123"); + expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123"); expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(""); await prepareCliBundleMcpCaptureAttempt({ mode: "claude-config-file", @@ -318,9 +318,7 @@ describe("prepareCliBundleMcpConfig", () => { const attemptRaw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as { mcpServers?: Record }>; }; - expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe( - "Bearer loopback-token-123", - ); + expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123"); expect(attemptRaw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe( "attempt-123", ); @@ -346,13 +344,13 @@ describe("prepareCliBundleMcpConfig", () => { workspaceDir, config: { plugins: { enabled: false } }, env: { - OPENCLAW_MCP_TOKEN: "loopback-token-123", + OPENCLAW_MCP_TOKEN: "lb-tk-123", OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123", }, }); expect(prepared.env).toEqual({ - OPENCLAW_MCP_TOKEN: "loopback-token-123", + OPENCLAW_MCP_TOKEN: "lb-tk-123", OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123", }); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 8d9788e7193..db2f37ae46c 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -122,6 +122,7 @@ function createTestMcpLoopbackServerConfig(port: number) { "x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}", "x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}", "x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}", + "x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}", "x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}", "x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}", "x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}", @@ -2804,6 +2805,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { cfg: expect.any(Object), sessionKey: "agent:main:test", messageProvider: undefined, + clientCaps: undefined, currentChannelId: undefined, currentThreadTs: undefined, currentMessageId: undefined, @@ -2811,6 +2813,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { accountId: undefined, inboundEventKind: undefined, sourceReplyDeliveryMode: undefined, + taskSuggestionDeliveryMode: undefined, requireExplicitMessageTarget: false, senderIsOwner: undefined, }); @@ -2965,6 +2968,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { config: createCliBackendConfig(), currentInboundEventKind: "room_event", messageChannel: "telegram", + clientCaps: ["tool-events", "inline-widgets"], currentChannelId: "telegram:-100123:topic:42", currentThreadTs: "42", currentMessageId: "reply-message-1", @@ -2977,6 +2981,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { expect(context.preparedBackend.env).toMatchObject({ OPENCLAW_MCP_SESSION_ID: "session-test", OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram", + OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets", OPENCLAW_MCP_CURRENT_CHANNEL_ID: "telegram:-100123:topic:42", OPENCLAW_MCP_CURRENT_THREAD_TS: "42", OPENCLAW_MCP_CURRENT_MESSAGE_ID: "reply-message-1", @@ -2990,6 +2995,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { expect(context.mcpDeliveryCapture).toBe(true); expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith( expect.objectContaining({ + clientCaps: ["tool-events", "inline-widgets"], taskSuggestionDeliveryMode: "gateway", requireExplicitMessageTarget: true, }), diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 372121196c0..375dee109a3 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -588,6 +588,7 @@ export async function prepareCliRunContext( OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "", OPENCLAW_MCP_SESSION_ID: params.sessionId, OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "", + OPENCLAW_MCP_CLIENT_CAPS: params.clientCaps?.join(",") ?? "", OPENCLAW_MCP_CURRENT_CHANNEL_ID: params.currentChannelId ?? "", OPENCLAW_MCP_CURRENT_THREAD_TS: params.currentThreadTs ?? "", OPENCLAW_MCP_CURRENT_MESSAGE_ID: @@ -710,6 +711,7 @@ export async function prepareCliRunContext( cfg: params.config ?? getRuntimeConfig(), sessionKey: params.sessionKey ?? "", messageProvider: params.messageChannel ?? params.messageProvider, + clientCaps: params.clientCaps, currentChannelId: params.currentChannelId, // CLI binding hashes must use session-stable prompt facts. Per-sender // and per-message scope stays in the runtime MCP env/list-call path. diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 25ee6151340..4f3fcce4ff4 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -120,6 +120,8 @@ export type RunCliAgentParams = { skillsSnapshot?: SkillSnapshot; messageChannel?: string; messageProvider?: string; + /** Capabilities declared by the gateway client that originated this run. */ + clientCaps?: string[]; currentChannelId?: string; chatId?: string; channelContext?: PluginHookChannelContext; diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 15ed0859f03..22bd829a6e5 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -1676,6 +1676,7 @@ describe("runAgentTurnWithFallback", () => { const followupRun = createFollowupRun(); followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.4"; + followupRun.run.clientCaps = ["tool-events", "inline-widgets"]; const typingSignals = createMockTypingSignaler(); const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); @@ -1691,6 +1692,7 @@ describe("runAgentTurnWithFallback", () => { expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", { provider: "codex-cli", model: "gpt-5.4", + clientCaps: ["tool-events", "inline-widgets"], }); }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 8e4b58a5cbb..5dce365ed58 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -2565,6 +2565,7 @@ async function runAgentTurnWithFallbackInternal( skillsSnapshot: params.followupRun.run.skillsSnapshot, messageChannel: params.followupRun.originatingChannel ?? undefined, messageProvider: hookMessageProvider, + clientCaps: params.followupRun.run.clientCaps, currentChannelId: params.followupRun.originatingTo ?? params.sessionCtx.OriginatingTo ?? diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 567f0143253..6a99289b2bd 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1300,6 +1300,7 @@ describe("createFollowupRunner runtime config", () => { provider: "anthropic", model: "claude-opus-4-7", messageProvider: "telegram", + clientCaps: ["tool-events", "inline-widgets"], senderId: "sender-42", senderIsOwner: true, cwd: "/tmp/task-repo", @@ -1320,6 +1321,7 @@ describe("createFollowupRunner runtime config", () => { expect(call.config).toBe(runtimeConfig); expect(call.cliSessionId).toBe("cli-session-1"); expect(call.messageChannel).toBe("telegram"); + expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]); expect(call.currentChannelId).toBe("telegram:-100123:topic:42"); expect(call.currentThreadTs).toBe("42"); expect(call.currentMessageId).toBe("reply-42"); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index b193a71476a..6d5b8c7f90f 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1216,6 +1216,7 @@ export function createFollowupRunner(params: { originatingChannel: queued.originatingChannel, provider: run.messageProvider, }), + clientCaps: run.clientCaps, currentChannelId: queued.originatingTo, senderId: run.senderId, chatId: queued.originatingChatId, diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index 0e105ed319f..e9d6da99966 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -392,6 +392,7 @@ const MCP_CONTEXT_HEADERS = { "x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}", "x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}", "x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}", + "x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}", "x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}", "x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}", "x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}", diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index eb24508c2dc..2b4a4ffd0a3 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -56,6 +56,7 @@ type McpRequestContext = { sessionKey: string; sessionId: string | undefined; messageProvider: string | undefined; + clientCaps: string[] | undefined; currentChannelId: string | undefined; currentThreadTs: string | undefined; currentMessageId: string | undefined; @@ -370,6 +371,13 @@ export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefine return normalizeOptionalString(getHeader(req, "x-openclaw-cli-capture-key")); } +function normalizeMcpClientCapsHeader(value: string | undefined): string[] | undefined { + const clientCaps = [...new Set((value ?? "").split(",").map((cap) => cap.trim()))].filter( + Boolean, + ); + return clientCaps.length > 0 ? clientCaps : undefined; +} + export function resolveMcpRequestContext( req: IncomingMessage, cfg: OpenClawConfig, @@ -382,6 +390,7 @@ export function resolveMcpRequestContext( sessionKey: auth.boundSessionKey, sessionId: undefined, messageProvider: undefined, + clientCaps: undefined, currentChannelId: undefined, currentThreadTs: undefined, currentMessageId: undefined, @@ -399,6 +408,9 @@ export function resolveMcpRequestContext( sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")), messageProvider: normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined, + // The token-authenticated loopback client is gateway-spawned on 127.0.0.1. Caps only + // widen tool availability; sender ownership remains derived from the bearer token. + clientCaps: normalizeMcpClientCapsHeader(getHeader(req, "x-openclaw-client-caps")), currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")), currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")), currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")), diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 1996b078738..45e87fe74e4 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -35,6 +35,7 @@ type McpLoopbackScopeParams = { yieldContextCacheKey?: string; onYield?: (message: string) => Promise | void; messageProvider: string | undefined; + clientCaps?: string[]; currentChannelId: string | undefined; currentThreadTs: string | undefined; currentMessageId: string | number | undefined; @@ -68,11 +69,14 @@ export class McpLoopbackToolCache { #entries = new Map(); resolve(params: McpLoopbackScopeParams): CachedScopedTools { + // Callers differing only in capabilities must not share cached tool lists. + const clientCapsCacheKey = [...new Set(params.clientCaps ?? [])].toSorted().join(","); const cacheKey = [ params.sessionKey, params.sessionId ?? "", params.yieldContextCacheKey ?? "", params.messageProvider ?? "", + clientCapsCacheKey, params.currentChannelId ?? "", params.currentThreadTs ?? "", params.currentMessageId != null ? String(params.currentMessageId) : "", diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index f779cf872d3..da9c8661d1e 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -45,6 +45,7 @@ type ScopedToolsCall = { onYield?: (message: string) => Promise | void; accountId?: string; messageProvider?: string; + clientCaps?: string[]; currentChannelId?: string; currentThreadTs?: string; currentMessageId?: string | number; @@ -808,6 +809,7 @@ describe("mcp loopback server", () => { "x-openclaw-session-id": "session-123", "x-openclaw-account-id": "work", "x-openclaw-message-channel": "telegram", + "x-openclaw-client-caps": "tool-events,inline-widgets", "x-openclaw-current-channel-id": "telegram:chat123", "x-openclaw-current-thread-ts": "42", "x-openclaw-current-message-id": "reply-message-1", @@ -826,6 +828,7 @@ describe("mcp loopback server", () => { expect(call.sessionId).toBe("session-123"); expect(call.accountId).toBe("work"); expect(call.messageProvider).toBe("telegram"); + expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]); expect(call.currentChannelId).toBe("telegram:chat123"); expect(call.currentThreadTs).toBe("42"); expect(call.currentMessageId).toBe("reply-message-1"); @@ -845,6 +848,27 @@ describe("mcp loopback server", () => { ]); }); + it("normalizes whitespace, duplicate, and empty client capability headers", async () => { + const { runtime } = await startLoopbackServerForTest(); + const sendWithCaps = async (clientCaps: string, currentMessageId: string) => + await sendLoopbackToolsList({ + token: runtime.ownerToken, + headers: { + "x-session-key": "agent:main:main", + "x-openclaw-current-message-id": currentMessageId, + "x-openclaw-client-caps": clientCaps, + }, + }); + + expect( + (await sendWithCaps(" tool-events, inline-widgets,tool-events, , ", "message-capped")).status, + ).toBe(200); + expect((await sendWithCaps("", "message-capless")).status).toBe(200); + + expect(getScopedToolsCall(0).clientCaps).toEqual(["tool-events", "inline-widgets"]); + expect(getScopedToolsCall(1).clientCaps).toBeUndefined(); + }); + it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => { resetAttachGrantsForTest(); const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" }); @@ -860,6 +884,7 @@ describe("mcp loopback server", () => { headers: jsonHeaders({ "x-session-key": "agent:main:SPOOFED-other-session", "x-openclaw-message-channel": "telegram", + "x-openclaw-client-caps": "inline-widgets", "x-openclaw-account-id": "victim-account", "x-openclaw-current-channel-id": "telegram:victim-chat", "x-openclaw-current-thread-ts": "999", @@ -875,6 +900,7 @@ describe("mcp loopback server", () => { expect(call.senderIsOwner).toBe(false); expect(call.surface).toBe("loopback"); expect(call.messageProvider).toBeUndefined(); + expect(call.clientCaps).toBeUndefined(); expect(call.accountId).toBeUndefined(); expect(call.currentChannelId).toBeUndefined(); expect(call.currentThreadTs).toBeUndefined(); @@ -987,6 +1013,39 @@ describe("mcp loopback server", () => { expect(getScopedToolsCall(5).taskSuggestionDeliveryMode).toBe("gateway"); }); + it("keeps capless and capability-scoped tool cache entries separate", async () => { + resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => { + const call = input as ScopedToolsCall; + return { + agentId: "main", + tools: [ + makeMessageTool(), + ...(call.clientCaps?.includes("inline-widgets") + ? [makeMockTool({ name: "show_widget" })] + : []), + ], + }; + }); + const { runtime } = await startLoopbackServerForTest(); + const listTools = async (clientCaps?: string) => + await readMcpPayload( + await sendLoopbackToolsList({ + token: runtime.ownerToken, + headers: { + "x-session-key": "agent:main:main", + ...(clientCaps ? { "x-openclaw-client-caps": clientCaps } : {}), + }, + }), + ); + + const capless = await listTools(); + const capped = await listTools("inline-widgets"); + + expect(capless.result?.tools?.map((tool) => tool.name)).not.toContain("show_widget"); + expect(capped.result?.tools?.map((tool) => tool.name)).toContain("show_widget"); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(2); + }); + it("keeps explicit non-owner and unknown-owner loopback cache entries separate", () => { const cache = new McpLoopbackToolCache(); const baseParams = { @@ -2190,6 +2249,9 @@ describe("createMcpLoopbackServerConfig", () => { expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe( "${OPENCLAW_MCP_MESSAGE_CHANNEL}", ); + expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-client-caps"]).toBe( + "${OPENCLAW_MCP_CLIENT_CAPS}", + ); expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-channel-id"]).toBe( "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}", ); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 1174436afa2..c429da30f6b 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -235,6 +235,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ yieldContextCacheKey: yieldContext?.cacheKey, onYield: yieldContext?.onYield, messageProvider: requestContext.messageProvider, + clientCaps: requestContext.clientCaps, currentChannelId: requestContext.currentChannelId, currentThreadTs: requestContext.currentThreadTs, currentMessageId: requestContext.currentMessageId, diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 8b74073a63c..20fe8d16457 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; type CreateOpenClawToolsArg = { + clientCaps?: string[]; cronCreatorToolAllowlist?: Array; inheritedToolDenylist?: string[]; pluginToolDenylist?: string[]; @@ -43,6 +44,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { }); function readCreateToolsArgs(index = 0): { + clientCaps?: string[]; cronCreatorToolAllowlist?: Array; inheritedToolDenylist?: string[]; pluginToolDenylist?: string[]; @@ -52,12 +54,24 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { throw new Error("expected createOpenClawTools args"); } return args as { + clientCaps?: string[]; cronCreatorToolAllowlist?: Array; inheritedToolDenylist?: string[]; pluginToolDenylist?: string[]; }; } + it("passes gateway client capabilities into tool construction", () => { + resolveGatewayScopedTools({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:direct:test", + surface: "loopback", + clientCaps: ["tool-events", "inline-widgets"], + }); + + expect(readCreateToolsArgs().clientCaps).toEqual(["tool-events", "inline-widgets"]); + }); + it("filters loopback dedup exclusions without inheriting policy denies", () => { const result = resolveGatewayScopedTools({ cfg: {} as OpenClawConfig,