Files
openclaw/src/gateway/mcp-http.runtime.ts
Josh Avant fbd330b7aa fix(channels): honor configured read target policies (#99905)
* fix(channels): enforce configured read targets

* test(channels): align policy checks with boundaries

* fix: bind channel reads to trusted turn context

* test: satisfy gateway lint

* fix: narrow message action channel imports

* fix(feishu): authorize message reads before provider access

* fix(slack): await reaction clear authorization

* fix(channels): align provider action contracts

* fix(matrix): read direct-room account data before sync

* fix(channels): reject unsupported attachment actions early

* fix: restore trusted operator conversation reads

* fix(matrix): authorize pin actions before provider reads

* fix: preserve trusted channel read workflows

* fix(discord): resolve current channel ids consistently

* fix(agents): preserve message action turn capability

* fix(plugins): enforce host-owned read provenance

* fix(channels): harden Teams and Discord read policy

* fix(channels): preserve exact-current action compatibility

* fix(imessage): authorize trusted current chat aliases

* fix(channels): preserve normalized current aliases

* fix(channels): preserve external current target aliases

* fix: reconcile channel policy with current main

* fix(discord): isolate DM read policy

* fix(channels): enforce provider read gates

* fix(gateway): await serialized message action identity tokens

* fix(ci): refresh channel protocol contracts
2026-07-10 22:29:37 -05:00

188 lines
6.8 KiB
TypeScript

// MCP loopback runtime scope cache.
// Resolves Gateway-visible tools for MCP clients with short-lived schema caching.
import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js";
import type { ExecPolicyOverrides, ExecSessionDefaults } from "../agents/exec-defaults.js";
import type {
SourceReplyDeliveryMode,
TaskSuggestionDeliveryMode,
} from "../auto-reply/get-reply-options.types.js";
import type { InboundEventKind } from "../channels/inbound-event/kind.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
import {
buildMcpToolSchema,
type McpLoopbackTool,
type McpToolSchemaEntry,
} from "./mcp-http.schema.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
// MCP loopback runtime scopes gateway tools to the current session/channel
// context and caches the expensive schema projection for short bursts of tool
// list/call traffic from the same MCP client.
const TOOL_CACHE_TTL_MS = 30_000;
const TOOL_CACHE_MAX_ENTRIES = 256;
const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]);
type CachedScopedTools = {
agentId: string | undefined;
tools: McpLoopbackTool[];
toolSchema: McpToolSchemaEntry[];
configRef: OpenClawConfig;
time: number;
};
type McpLoopbackScopeParams = {
cfg: OpenClawConfig;
sessionKey: string;
runtimePolicySessionKey?: string;
agentId?: string;
sessionId?: string;
modelProvider?: string;
modelId?: string;
yieldContextCacheKey?: string;
onYield?: (message: string) => Promise<void> | void;
messageProvider: string | undefined;
clientCaps?: string[];
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | number | undefined;
currentInboundAudio: boolean | undefined;
accountId: string | undefined;
inboundEventKind: InboundEventKind | undefined;
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
requireExplicitMessageTarget?: boolean;
senderIsOwner: boolean | undefined;
nodeExecAllowed?: boolean;
execSession?: ExecSessionDefaults;
execOverrides?: ExecPolicyOverrides;
bashElevated?: ExecElevatedDefaults;
trigger?: string;
approvalReviewerDeviceId?: string;
channelContext?: PluginHookChannelContext;
senderName?: string;
senderUsername?: string;
senderE164?: string;
groupId?: string;
groupChannel?: string;
groupSpace?: string;
spawnedBy?: string;
};
/** Resolves loopback-visible tools after applying gateway scope and native-tool exclusions. */
export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): {
agentId: string | undefined;
tools: McpLoopbackTool[];
} {
const excludeToolNames = new Set(NATIVE_TOOL_EXCLUDE);
if (params.nodeExecAllowed === true) {
excludeToolNames.delete("exec");
}
const scoped = resolveGatewayScopedTools({
...params,
conversationReadOrigin: "delegated",
surface: "loopback",
excludeToolNames,
includeNodeExecTool: params.nodeExecAllowed === true,
});
return {
agentId: scoped.agentId,
tools: scoped.tools,
};
}
/** Short-lived cache for loopback tool lists keyed by session/channel context. */
export class McpLoopbackToolCache {
#entries = new Map<string, CachedScopedTools>();
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.runtimePolicySessionKey ?? "",
params.agentId ?? "",
params.sessionId ?? "",
params.modelProvider ?? "",
params.modelId ?? "",
params.yieldContextCacheKey ?? "",
params.messageProvider ?? "",
clientCapsCacheKey,
params.currentChannelId ?? "",
params.currentThreadTs ?? "",
params.currentMessageId != null ? String(params.currentMessageId) : "",
params.currentInboundAudio === true ? "audio" : "no-audio",
params.accountId ?? "",
params.inboundEventKind ?? "",
params.sourceReplyDeliveryMode ?? "",
params.taskSuggestionDeliveryMode ?? "",
params.requireExplicitMessageTarget === true ? "explicit-message-target" : "",
params.nodeExecAllowed === true ? "node-exec" : "",
params.execSession?.execHost ?? "",
params.execSession?.execSecurity ?? "",
params.execSession?.execAsk ?? "",
params.execSession?.execNode ?? "",
params.execOverrides?.host ?? "",
params.execOverrides?.security ?? "",
params.execOverrides?.ask ?? "",
params.execOverrides?.node ?? "",
params.bashElevated ? "elevated-present" : "elevated-absent",
params.bashElevated?.enabled === true ? "elevated-enabled" : "elevated-disabled",
params.bashElevated?.allowed === true ? "elevated-allowed" : "elevated-blocked",
params.bashElevated?.defaultLevel ?? "",
params.bashElevated?.fullAccessAvailable === true
? "full-access-available"
: params.bashElevated?.fullAccessAvailable === false
? "full-access-unavailable"
: "",
params.bashElevated?.fullAccessBlockedReason ?? "",
params.trigger ?? "",
params.approvalReviewerDeviceId ?? "",
params.channelContext?.sender?.id ?? "",
params.channelContext?.chat?.id ?? "",
params.senderName ?? "",
params.senderUsername ?? "",
params.senderE164 ?? "",
params.groupId ?? "",
params.groupChannel ?? "",
params.groupSpace ?? "",
params.spawnedBy ?? "",
params.senderIsOwner === true
? "owner"
: params.senderIsOwner === false
? "non-owner"
: "unknown-owner",
].join("\u0000");
const now = Date.now();
for (const [key, entry] of this.#entries) {
if (now - entry.time >= TOOL_CACHE_TTL_MS) {
this.#entries.delete(key);
}
}
const cached = this.#entries.get(cacheKey);
// Config object identity is part of the cache contract so explicit gateway
// reloads invalidate tool scope and schema without filesystem polling.
if (cached && cached.configRef === params.cfg && now - cached.time < TOOL_CACHE_TTL_MS) {
return cached;
}
const next = resolveMcpLoopbackScopedTools(params);
const nextEntry: CachedScopedTools = {
agentId: next.agentId,
tools: next.tools,
toolSchema: buildMcpToolSchema(next.tools),
configRef: params.cfg,
time: now,
};
this.#entries.set(cacheKey, nextEntry);
while (this.#entries.size > TOOL_CACHE_MAX_ENTRIES) {
const oldestKey = this.#entries.keys().next().value;
if (oldestKey === undefined) {
break;
}
this.#entries.delete(oldestKey);
}
return nextEntry;
}
}