feat(gateway): show warm MCP tools in effective inventory

Add read-only MCP visibility to `tools.effective` by projecting MCP tools only after a session catalog has already been warmed by an agent turn. Keep the gateway additive: no `tools.effective.refresh`, no forced MCP startup, and no behavior change for MCP loading.

Verification:
- `git diff --check origin/main..HEAD`
- `node scripts/run-vitest.mjs run --config test/vitest/vitest.agents.config.ts --reporter=verbose src/agents/tools-effective-inventory.test.ts`
- GitHub checks green on `a8a7f8442adb216f60da24d50118374a15c62e06`, including `Real behavior proof`, `check-guards`, `check-prod-types`, `check-test-types`, `build-artifacts`, `Critical Quality (gateway-runtime-boundary)`, and `Critical Quality (network-runtime-boundary)`.

Co-authored-by: David Huang <nxmxbbd@gmail.com>
This commit is contained in:
David
2026-05-28 23:52:53 +08:00
committed by GitHub
parent b261e9e6dd
commit 7a36bb37af
20 changed files with 1046 additions and 221 deletions

View File

@@ -9,7 +9,12 @@ import {
normalizeReservedToolNames,
TOOL_NAME_SEPARATOR,
} from "./agent-bundle-mcp-names.js";
import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./agent-bundle-mcp-types.js";
import type {
BundleMcpToolRuntime,
McpCatalogTool,
McpToolCatalog,
SessionMcpRuntime,
} from "./agent-bundle-mcp-types.js";
import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js";
import type { AgentToolResult } from "./runtime/index.js";
import type { AnyAgentTool } from "./tools/common.js";
@@ -62,24 +67,18 @@ function toAgentToolResult(params: {
};
}
export async function materializeBundleMcpToolsForRun(params: {
runtime: SessionMcpRuntime;
/**
* Projects an already-listed MCP catalog into agent tools. Without `createExecute`,
* the projected tools are inventory-only and throw if execution is attempted.
*/
export function buildBundleMcpToolsFromCatalog(params: {
catalog: McpToolCatalog;
reservedToolNames?: Iterable<string>;
disposeRuntime?: () => Promise<void>;
}): Promise<BundleMcpToolRuntime> {
let disposed = false;
const releaseLease = params.runtime.acquireLease?.();
params.runtime.markUsed();
let catalog;
try {
catalog = await params.runtime.getCatalog();
} catch (error) {
releaseLease?.();
throw error;
}
createExecute?: (tool: McpCatalogTool) => AnyAgentTool["execute"];
}): AnyAgentTool[] {
const reservedNames = normalizeReservedToolNames(params.reservedToolNames);
const tools: BundleMcpToolRuntime["tools"] = [];
const sortedCatalogTools = [...catalog.tools].toSorted((a, b) => {
const tools: AnyAgentTool[] = [];
const sortedCatalogTools = [...params.catalog.tools].toSorted((a, b) => {
const serverOrder = a.safeServerName.localeCompare(b.safeServerName);
if (serverOrder !== 0) {
return serverOrder;
@@ -112,15 +111,11 @@ export async function materializeBundleMcpToolsForRun(params: {
label: tool.title ?? tool.toolName,
description: tool.description || tool.fallbackDescription,
parameters: normalizeToolParameterSchema(tool.inputSchema),
execute: async (_toolCallId: string, input: unknown) => {
params.runtime.markUsed();
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
return toAgentToolResult({
serverName: tool.serverName,
toolName: tool.toolName,
result,
});
},
execute:
params.createExecute?.(tool) ??
(async () => {
throw new Error("bundle-mcp catalog projection cannot execute tools");
}),
};
setPluginToolMeta(agentTool, {
pluginId: "bundle-mcp",
@@ -133,6 +128,37 @@ export async function materializeBundleMcpToolsForRun(params: {
// turns (defensive — listTools() order is usually stable but not guaranteed).
// Cannot fix name collisions: collision suffixes above are order-dependent.
tools.sort((a, b) => a.name.localeCompare(b.name));
return tools;
}
export async function materializeBundleMcpToolsForRun(params: {
runtime: SessionMcpRuntime;
reservedToolNames?: Iterable<string>;
disposeRuntime?: () => Promise<void>;
}): Promise<BundleMcpToolRuntime> {
let disposed = false;
const releaseLease = params.runtime.acquireLease?.();
params.runtime.markUsed();
let catalog;
try {
catalog = await params.runtime.getCatalog();
} catch (error) {
releaseLease?.();
throw error;
}
const tools = buildBundleMcpToolsFromCatalog({
catalog,
reservedToolNames: params.reservedToolNames,
createExecute: (tool) => async (_toolCallId: string, input: unknown) => {
params.runtime.markUsed();
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
return toAgentToolResult({
serverName: tool.serverName,
toolName: tool.toolName,
result,
});
},
});
return {
tools,

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeExecutable } from "./bundle-mcp-shared.test-harness.js";
import { createBundleMcpJsonSchemaValidator } from "./agent-bundle-mcp-runtime.js";
import { cleanupBundleMcpHarness } from "./agent-bundle-mcp-test-harness.js";
import {
@@ -13,6 +12,7 @@ import {
retireSessionMcpRuntimeForSessionKey,
} from "./agent-bundle-mcp-tools.js";
import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js";
import { writeExecutable } from "./bundle-mcp-shared.test-harness.js";
vi.mock("./embedded-agent-mcp.js", () => ({
loadEmbeddedAgentMcpConfig: (params: {
@@ -168,6 +168,7 @@ function makeRuntime(
markUsed: () => {
lastUsedAt = Date.now();
},
peekCatalog: () => null,
getCatalog: async () => ({
version: 1,
generatedAt: 0,
@@ -724,6 +725,46 @@ describe("session MCP runtime", () => {
expect(manager.listSessionIds()).not.toContain("session-a");
});
it("peeks existing runtimes and populated catalogs without creating new runtimes", async () => {
let catalogReady = false;
const createRuntime: RuntimeFactory = (params) => {
const base = makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]);
let cachedCatalog: ReturnType<SessionMcpRuntime["peekCatalog"]> = null;
return {
...base,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
configFingerprint: params.configFingerprint ?? "fingerprint",
peekCatalog: () => cachedCatalog,
getCatalog: async () => {
const catalog = await base.getCatalog();
cachedCatalog = catalog;
catalogReady = true;
return catalog;
},
};
};
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
expect(manager.peekSession({ sessionId: "session-peek" })).toBeUndefined();
const runtime = await manager.getOrCreate({
sessionId: "session-peek",
sessionKey: "agent:test:session-peek",
workspaceDir: "/workspace",
});
expect(manager.peekSession({ sessionId: "session-peek" })).toBe(runtime);
expect(manager.peekSession({ sessionKey: "agent:test:session-peek" })).toBe(runtime);
expect(runtime.peekCatalog()).toBeNull();
expect(catalogReady).toBe(false);
await runtime.getCatalog();
expect(catalogReady).toBe(true);
expect(runtime.peekCatalog()?.tools.map((tool) => tool.toolName)).toEqual(["bundle_probe"]);
});
it("recreates the session runtime when MCP config changes", async () => {
const createRuntime: RuntimeFactory = (params) => {
const probeText = String(

View File

@@ -3,12 +3,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js";
import type {
JsonSchemaType,
JsonSchemaValidator,
jsonSchemaValidator,
} from "@modelcontextprotocol/sdk/validation/types.js";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js";
import { Compile } from "typebox/compile";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
@@ -242,6 +242,33 @@ function loadSessionMcpConfig(params: {
};
}
/**
* Loads enabled MCP config metadata for a session without creating runtimes,
* connecting transports, or issuing MCP tools/list requests.
*/
export function resolveSessionMcpConfigSummary(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
}): { fingerprint: string; serverNames: string[] } {
const { loaded, fingerprint } = loadSessionMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
logDiagnostics: false,
});
return {
fingerprint,
serverNames: Object.keys(loaded.mcpServers).toSorted((a, b) => a.localeCompare(b)),
};
}
/** Returns the session MCP config fingerprint with the same no-runtime/no-connect contract as the summary helper. */
export function resolveSessionMcpConfigFingerprint(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
}): string {
return resolveSessionMcpConfigSummary(params).fingerprint;
}
function createDisposedError(sessionId: string): Error {
return new Error(`bundle-mcp runtime disposed for session ${sessionId}`);
}
@@ -421,6 +448,10 @@ export function createSessionMcpRuntime(params: {
};
},
getCatalog,
/** Synchronous catalog snapshot only; must not connect transports or issue tools/list. */
peekCatalog() {
return catalog;
},
markUsed() {
lastUsedAt = Date.now();
},
@@ -611,6 +642,13 @@ function createSessionMcpRuntimeManager(
resolveSessionId(sessionKey) {
return sessionIdBySessionKey.get(sessionKey);
},
/** Synchronous lookup only; must not create runtimes or connect transports. */
peekSession(params) {
const sessionId =
params.sessionId ??
(params.sessionKey ? sessionIdBySessionKey.get(params.sessionKey) : undefined);
return sessionId ? runtimesBySessionId.get(sessionId) : undefined;
},
async disposeSession(sessionId) {
const inFlight = createInFlight.get(sessionId);
createInFlight.delete(sessionId);
@@ -666,6 +704,19 @@ export async function getOrCreateSessionMcpRuntime(params: {
return await getSessionMcpRuntimeManager().getOrCreate(params);
}
/** Looks up an existing session MCP runtime without creating it or connecting transports. */
export function peekSessionMcpRuntime(params: {
sessionId?: string | null;
sessionKey?: string | null;
}): SessionMcpRuntime | undefined {
const sessionId = normalizeOptionalString(params.sessionId);
const sessionKey = normalizeOptionalString(params.sessionKey);
return getSessionMcpRuntimeManager().peekSession({
...(sessionId ? { sessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
});
}
export async function disposeSessionMcpRuntime(sessionId: string): Promise<void> {
await getSessionMcpRuntimeManager().disposeSession(sessionId);
}

View File

@@ -51,6 +51,18 @@ function makeToolRuntime(
},
tools,
}),
peekCatalog: () => ({
version: 1,
generatedAt: 0,
servers: {
[serverName]: {
serverName,
launchSummary: serverName,
toolCount: tools.length,
},
},
tools,
}),
callTool: async () => ({
content: [{ type: "text", text: params.resultText ?? "FROM-BUNDLE" }],
isError: false,

View File

@@ -55,6 +55,18 @@ function makeConfiguredRuntime(
},
tools,
}),
peekCatalog: () => ({
version: 1,
generatedAt: 0,
servers: {
[serverName]: {
serverName,
launchSummary: serverName,
toolCount: tools.length,
},
},
tools,
}),
callTool: async () => ({
content: [{ type: "text", text: "FROM-CONFIG" }],
isError: false,

View File

@@ -14,10 +14,14 @@ export {
disposeSessionMcpRuntime,
getOrCreateSessionMcpRuntime,
getSessionMcpRuntimeManager,
peekSessionMcpRuntime,
resolveSessionMcpConfigFingerprint,
resolveSessionMcpConfigSummary,
retireSessionMcpRuntime,
retireSessionMcpRuntimeForSessionKey,
} from "./agent-bundle-mcp-runtime.js";
export {
buildBundleMcpToolsFromCatalog,
createBundleMcpToolRuntime,
materializeBundleMcpToolsForRun,
} from "./agent-bundle-mcp-materialize.js";

View File

@@ -40,7 +40,10 @@ export type SessionMcpRuntime = {
lastUsedAt: number;
activeLeases?: number;
acquireLease?: () => () => void;
/** Lists tools if needed and may connect MCP transports. */
getCatalog: () => Promise<McpToolCatalog>;
/** Returns the cached catalog only; must not start runtimes, connect transports, or issue tools/list. */
peekCatalog: () => McpToolCatalog | null;
markUsed: () => void;
callTool: (serverName: string, toolName: string, input: unknown) => Promise<CallToolResult>;
dispose: () => Promise<void>;
@@ -55,6 +58,11 @@ export type SessionMcpRuntimeManager = {
}) => Promise<SessionMcpRuntime>;
bindSessionKey: (sessionKey: string, sessionId: string) => void;
resolveSessionId: (sessionKey: string) => string | undefined;
/** Looks up an existing runtime only; must not create runtimes or connect transports. */
peekSession: (params: {
sessionId?: string;
sessionKey?: string;
}) => SessionMcpRuntime | undefined;
disposeSession: (sessionId: string) => Promise<void>;
disposeAll: () => Promise<void>;
sweepIdleRuntimes: () => Promise<number>;

View File

@@ -228,6 +228,35 @@ describe("resolveEffectiveToolInventory", () => {
});
});
it("groups bundled MCP tools separately from generic plugin tools", async () => {
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({ name: "reproProbe__probe_tool", label: "Probe", description: "Probe MCP" }),
],
pluginMeta: { reproProbe__probe_tool: { pluginId: "bundle-mcp" } },
});
const result = resolveEffectiveToolInventory({ cfg: {} });
expect(result.groups).toEqual([
{
id: "mcp",
label: "MCP server tools",
source: "mcp",
tools: [
{
id: "reproProbe__probe_tool",
label: "Probe",
description: "Probe MCP",
rawDescription: "Probe MCP",
source: "mcp",
pluginId: "bundle-mcp",
},
],
},
]);
});
it("disambiguates duplicate labels with source ids", async () => {
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [

View File

@@ -67,6 +67,9 @@ function resolveEffectiveToolSource(
const pluginMeta =
getPluginToolMeta(tool) ?? (fallbackTool ? getPluginToolMeta(fallbackTool) : undefined);
if (pluginMeta) {
if (pluginMeta.pluginId === "bundle-mcp") {
return { source: "mcp", pluginId: pluginMeta.pluginId };
}
return { source: "plugin", pluginId: pluginMeta.pluginId };
}
const channelMeta =
@@ -84,6 +87,8 @@ function groupLabel(source: EffectiveToolSource): string {
return "Connected tools";
case "channel":
return "Channel tools";
case "mcp":
return "MCP server tools";
default:
return "Built-in tools";
}
@@ -214,6 +219,108 @@ function disambiguateLabels(entries: EffectiveToolInventoryEntry[]): EffectiveTo
});
}
export function buildEffectiveToolInventoryEntries(
tools: readonly AnyAgentTool[],
rawToolsByName: ReadonlyMap<string, AnyAgentTool> = new Map(),
): EffectiveToolInventoryEntry[] {
// Key metadata by plugin ownership and tool name so only the owning plugin can
// project display/risk metadata for its own tool.
const pluginToolMetadata = new Map(
(getActivePluginRegistry()?.toolMetadata ?? []).map((entry) => [
buildPluginToolMetadataKey(entry.pluginId, entry.metadata.toolName),
entry.metadata,
]),
);
return disambiguateLabels(
tools
.map((tool) => {
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(tool.name));
const metadata = source.pluginId
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, tool.name))
: undefined;
return Object.assign(
{
id: tool.name,
label:
normalizeOptionalString(metadata?.displayName) ?? resolveEffectiveToolLabel(tool),
description:
normalizeOptionalString(metadata?.description) ?? summarizeToolDescription(tool),
rawDescription:
normalizeOptionalString(metadata?.description) ??
resolveRawToolDescription(tool) ??
summarizeToolDescription(tool),
...(metadata?.risk ? { risk: metadata.risk } : {}),
...(metadata?.tags ? { tags: metadata.tags } : {}),
},
source,
) satisfies EffectiveToolInventoryEntry;
})
.toSorted((a, b) => a.label.localeCompare(b.label)),
);
}
export function buildEffectiveToolInventoryGroups(
entries: readonly EffectiveToolInventoryEntry[],
): EffectiveToolInventoryGroup[] {
const groupsBySource = new Map<EffectiveToolSource, EffectiveToolInventoryEntry[]>();
for (const entry of entries) {
const tools = groupsBySource.get(entry.source) ?? [];
tools.push(entry);
groupsBySource.set(entry.source, tools);
}
return (["core", "plugin", "channel", "mcp"] as const)
.map((source) => {
const tools = groupsBySource.get(source);
if (!tools || tools.length === 0) {
return null;
}
return {
id: source,
label: groupLabel(source),
source,
tools,
} satisfies EffectiveToolInventoryGroup;
})
.filter((group): group is EffectiveToolInventoryGroup => group !== null);
}
export function buildRuntimeCompatibleToolInventory(params: {
tools: readonly AnyAgentTool[];
cfg: OpenClawConfig;
workspaceDir?: string;
modelProvider?: string;
modelId?: string;
modelApi?: string | null;
runtimeModel?: ProviderRuntimeModel;
}): {
entries: EffectiveToolInventoryEntry[];
notices: EffectiveToolInventoryNotice[];
} {
const rawToolsByName = new Map(params.tools.map((tool) => [tool.name, tool]));
const normalizedTools = normalizeAgentRuntimeTools({
// Schema normalization can replace tool definitions, so hand the runtime
// policy a mutable copy while keeping this inventory API readonly.
tools: [...params.tools],
provider: params.modelProvider ?? "",
config: params.cfg,
workspaceDir: params.workspaceDir,
modelId: params.modelId,
modelApi: params.modelApi ?? undefined,
model: params.runtimeModel,
});
const projection = filterRuntimeCompatibleTools(normalizedTools);
return {
entries: buildEffectiveToolInventoryEntries(projection.tools, rawToolsByName),
notices: buildUnsupportedToolSchemaNotices({
diagnostics: projection.diagnostics,
tools: normalizedTools,
rawToolsByName,
}),
};
}
function applyProviderTransportNormalization(params: {
cfg: OpenClawConfig;
provider: string;
@@ -443,17 +550,15 @@ export function resolveEffectiveToolInventory(
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
disableMessageTool: params.disableMessageTool,
});
const rawToolsByName = new Map(effectiveTools.map((tool) => [tool.name, tool]));
const normalizedEffectiveTools = normalizeAgentRuntimeTools({
const projectedInventory = buildRuntimeCompatibleToolInventory({
tools: effectiveTools,
provider: params.modelProvider ?? "",
config: params.cfg,
cfg: params.cfg,
workspaceDir,
modelProvider: params.modelProvider,
modelId: params.modelId,
modelApi: runtimeModelContext.modelApi,
model: runtimeModelContext.runtimeModel,
runtimeModel: runtimeModelContext.runtimeModel,
});
const toolSchemaProjection = filterRuntimeCompatibleTools(normalizedEffectiveTools);
const effectivePolicy = resolveEffectiveToolPolicy({
config: params.cfg,
agentId,
@@ -462,70 +567,12 @@ export function resolveEffectiveToolInventory(
modelId: params.modelId,
});
const profile = effectivePolicy.providerProfile ?? effectivePolicy.profile ?? "full";
// Key metadata by plugin ownership and tool name so only the owning plugin can
// project display/risk metadata for its own tool.
const pluginToolMetadata = new Map(
(getActivePluginRegistry()?.toolMetadata ?? []).map((entry) => [
buildPluginToolMetadataKey(entry.pluginId, entry.metadata.toolName),
entry.metadata,
]),
);
const entries = disambiguateLabels(
toolSchemaProjection.tools
.map((tool) => {
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(tool.name));
const metadata = source.pluginId
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, tool.name))
: undefined;
return Object.assign(
{
id: tool.name,
label:
normalizeOptionalString(metadata?.displayName) ?? resolveEffectiveToolLabel(tool),
description:
normalizeOptionalString(metadata?.description) ?? summarizeToolDescription(tool),
rawDescription:
normalizeOptionalString(metadata?.description) ??
resolveRawToolDescription(tool) ??
summarizeToolDescription(tool),
...(metadata?.risk ? { risk: metadata.risk } : {}),
...(metadata?.tags ? { tags: metadata.tags } : {}),
},
source,
) satisfies EffectiveToolInventoryEntry;
})
.toSorted((a, b) => a.label.localeCompare(b.label)),
);
const entries = projectedInventory.entries;
const notices = [
...buildUnsupportedToolSchemaNotices({
diagnostics: toolSchemaProjection.diagnostics,
tools: normalizedEffectiveTools,
rawToolsByName,
}),
...projectedInventory.notices,
...(buildToolInventoryNotices({ cfg: params.cfg, profile, entries, effectivePolicy }) ?? []),
];
const groupsBySource = new Map<EffectiveToolSource, EffectiveToolInventoryEntry[]>();
for (const entry of entries) {
const tools = groupsBySource.get(entry.source) ?? [];
tools.push(entry);
groupsBySource.set(entry.source, tools);
}
const groups = (["core", "plugin", "channel"] as const)
.map((source) => {
const tools = groupsBySource.get(source);
if (!tools || tools.length === 0) {
return null;
}
return {
id: source,
label: groupLabel(source),
source,
tools,
} satisfies EffectiveToolInventoryGroup;
})
.filter((group): group is EffectiveToolInventoryGroup => group !== null);
const groups = buildEffectiveToolInventoryGroups(entries);
return { agentId, profile, groups, ...(notices.length > 0 ? { notices } : {}) };
}

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
export type EffectiveToolSource = "core" | "plugin" | "channel";
export type EffectiveToolSource = "core" | "plugin" | "channel" | "mcp";
export type EffectiveToolInventoryEntry = {
id: string;

View File

@@ -572,7 +572,12 @@ export const ToolsEffectiveEntrySchema = Type.Object(
label: NonEmptyString,
description: Type.String(),
rawDescription: Type.String(),
source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]),
source: Type.Union([
Type.Literal("core"),
Type.Literal("plugin"),
Type.Literal("channel"),
Type.Literal("mcp"),
]),
pluginId: Type.Optional(NonEmptyString),
channelId: Type.Optional(NonEmptyString),
risk: Type.Optional(
@@ -585,9 +590,19 @@ export const ToolsEffectiveEntrySchema = Type.Object(
export const ToolsEffectiveGroupSchema = Type.Object(
{
id: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]),
id: Type.Union([
Type.Literal("core"),
Type.Literal("plugin"),
Type.Literal("channel"),
Type.Literal("mcp"),
]),
label: NonEmptyString,
source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]),
source: Type.Union([
Type.Literal("core"),
Type.Literal("plugin"),
Type.Literal("channel"),
Type.Literal("mcp"),
]),
tools: Type.Array(ToolsEffectiveEntrySchema),
},
{ additionalProperties: false },

View File

@@ -1,8 +1,19 @@
export { listAgentIds, resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
export {
listAgentIds,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
export {
resolveEffectiveToolInventory,
resolveEffectiveToolInventoryRuntimeModelContext,
} from "../../agents/tools-effective-inventory.js";
export {
buildBundleMcpToolsFromCatalog,
peekSessionMcpRuntime,
resolveSessionMcpConfigSummary,
} from "../../agents/agent-bundle-mcp-tools.js";
export { applyFinalEffectiveToolPolicy } from "../../agents/embedded-agent-runner/effective-tool-policy.js";
export { resolveReplyToMode } from "../../auto-reply/reply/reply-threading.js";
export { resolveRuntimeConfigCacheKey } from "../../config/config.js";
export {

View File

@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { McpToolCatalog, SessionMcpRuntime } from "../../agents/agent-bundle-mcp-types.js";
import { setPluginToolMeta } from "../../plugins/tools.js";
import { ErrorCodes } from "../protocol/index.js";
import { testing, toolsEffectiveHandlers } from "./tools-effective.js";
@@ -9,6 +11,14 @@ const runtimeMocks = vi.hoisted(() => ({
accountId: "acct-1",
threadId: "thread-2",
})),
applyFinalEffectiveToolPolicy: vi.fn(
(params: { bundledTools: unknown[] }) => params.bundledTools,
),
buildBundleMcpToolsFromCatalog: vi.fn(() => [] as unknown[]),
getActivePluginChannelRegistryVersion: vi.fn(() => 1),
getActivePluginRegistryVersion: vi.fn(() => 1),
resolveRuntimeConfigCacheKey: vi.fn(() => "runtime:1:test"),
resolveAgentDir: vi.fn(() => "/tmp/agents/main/agent"),
listAgentIds: vi.fn(() => ["main"]),
getRuntimeConfig: vi.fn(() => ({})),
loadSessionEntry: vi.fn(() => ({
@@ -27,12 +37,18 @@ const runtimeMocks = vi.hoisted(() => ({
chatType: "group",
modelProvider: "openai",
model: "gpt-4.1",
spawnedBy: "agent:main:telegram:group:parent-group",
spawnedWorkspaceDir: undefined as string | undefined,
},
})),
getActivePluginChannelRegistryVersion: vi.fn(() => 1),
getActivePluginRegistryVersion: vi.fn(() => 1),
resolveRuntimeConfigCacheKey: vi.fn(() => "runtime:1:test"),
resolveAgentDir: vi.fn(() => "/tmp/agents/main/agent"),
peekSessionMcpRuntime: vi.fn<
() => Pick<SessionMcpRuntime, "configFingerprint" | "peekCatalog" | "workspaceDir"> | undefined
>(() => undefined),
resolveSessionMcpConfigSummary: vi.fn(() => ({
fingerprint: "mcp:1:test",
serverNames: [] as string[],
})),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
resolveEffectiveToolInventory: vi.fn(() => ({
agentId: "main",
profile: "coding",
@@ -74,10 +90,12 @@ type RespondCall = [boolean, unknown?, { code: number; message: string }?];
type ToolsEffectivePayload = {
agentId?: string;
profile?: string;
notices?: Array<{ id?: string; severity?: string; message?: string }>;
groups?: Array<{
id?: string;
label?: string;
source?: string;
tools?: Array<{ id?: string; source?: string }>;
tools?: Array<{ id?: string; label?: string; source?: string; pluginId?: string }>;
}>;
};
@@ -108,13 +126,47 @@ function firstRespondCall(respond: ReturnType<typeof vi.fn>): RespondCall | unde
return respond.mock.calls[0] as RespondCall | undefined;
}
function makeMcpTool(params: Record<string, unknown> = { type: "object", properties: {} }) {
const mcpTool = {
name: "reproProbe__probe_tool",
label: "Probe Tool",
description: "Probe from MCP",
parameters: params,
execute: vi.fn(),
};
setPluginToolMeta(mcpTool as never, { pluginId: "bundle-mcp", optional: false });
return mcpTool;
}
describe("tools.effective handler", () => {
beforeEach(() => {
vi.clearAllMocks();
testing.resetToolsEffectiveCacheForTest();
testing.resetToolsEffectiveNowForTest();
runtimeMocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace-main");
runtimeMocks.resolveAgentDir.mockReturnValue("/tmp/agents/main/agent");
runtimeMocks.getActivePluginChannelRegistryVersion.mockReturnValue(1);
runtimeMocks.getActivePluginRegistryVersion.mockReturnValue(1);
runtimeMocks.resolveRuntimeConfigCacheKey.mockReturnValue("runtime:1:test");
runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext.mockReturnValue({
modelApi: "openai-responses",
runtimeModel: {
id: "gpt-4.1",
name: "GPT 4.1",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
},
});
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValue({
fingerprint: "mcp:1:test",
serverNames: [] as string[],
});
runtimeMocks.peekSessionMcpRuntime.mockReturnValue(undefined);
runtimeMocks.buildBundleMcpToolsFromCatalog.mockReturnValue([]);
runtimeMocks.applyFinalEffectiveToolPolicy.mockImplementation(
(params: { bundledTools: unknown[] }) => params.bundledTools,
);
});
it("rejects invalid params", async () => {
@@ -172,7 +224,7 @@ describe("tools.effective handler", () => {
expect(call?.[2]?.message).toContain('unknown session key "missing-session"');
});
it("returns the effective runtime inventory", async () => {
it("returns the read-only effective runtime inventory without MCP startup", async () => {
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const call = firstRespondCall(respond);
@@ -183,7 +235,6 @@ describe("tools.effective handler", () => {
expect(payload?.groups?.[0]?.id).toBe("core");
expect(payload?.groups?.[0]?.source).toBe("core");
expect(payload?.groups?.[0]?.tools?.[0]?.id).toBe("exec");
expect(payload?.groups?.[0]?.tools?.[0]?.source).toBe("core");
const inventoryParams = resolveEffectiveToolInventoryArg();
expect(inventoryParams?.currentChannelId).toBe("channel-1");
expect(inventoryParams?.currentThreadTs).toBe("thread-2");
@@ -196,6 +247,7 @@ describe("tools.effective handler", () => {
expect(inventoryParams?.modelProvider).toBe("openai");
expect(inventoryParams?.modelId).toBe("gpt-4.1");
expect(inventoryParams?.agentDir).toBe("/tmp/agents/main/agent");
expect(inventoryParams?.workspaceDir).toBe("/tmp/workspace-main");
expect(inventoryParams?.modelApi).toBe("openai-responses");
expect(inventoryParams?.runtimeModel).toMatchObject({
id: "gpt-4.1",
@@ -207,13 +259,18 @@ describe("tools.effective handler", () => {
expect.objectContaining({
agentId: "main",
agentDir: "/tmp/agents/main/agent",
workspaceDir: "/tmp/workspace-main",
modelProvider: "openai",
modelId: "gpt-4.1",
}),
);
});
it("serves repeated requests from the fresh inventory cache", async () => {
it("serves repeated requests from the fresh base inventory cache while still peeking MCP state", async () => {
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValue({
fingerprint: "mcp:1:test",
serverNames: ["reproProbe"],
});
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();
const second = createInvokeParams({ sessionKey: "main:abc" });
@@ -221,11 +278,32 @@ describe("tools.effective handler", () => {
expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(1);
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledTimes(1);
expect(runtimeMocks.peekSessionMcpRuntime).toHaveBeenCalledTimes(2);
expect(runtimeMocks.resolveSessionMcpConfigSummary).toHaveBeenCalledTimes(1);
expect(firstRespondCall(first.respond)?.[0]).toBe(true);
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
it("invalidates the cache when only the channel registry version changes", async () => {
it("keeps separate base inventory cache entries for spawned workspaces", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();
const loaded = runtimeMocks.loadSessionEntry();
runtimeMocks.loadSessionEntry.mockReturnValueOnce({
...loaded,
entry: {
...loaded.entry,
spawnedWorkspaceDir: "/tmp/workspace-sandbox",
},
});
const second = createInvokeParams({ sessionKey: "main:abc" });
await second.invoke();
expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(2);
expect(resolveEffectiveToolInventoryArg(1)?.workspaceDir).toBe("/tmp/workspace-sandbox");
});
it("invalidates the base inventory cache when only the channel registry version changes", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();
@@ -237,7 +315,7 @@ describe("tools.effective handler", () => {
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
it("does not resolve runtime model context for fresh inventory cache hits", async () => {
it("does not resolve runtime model context for fresh base inventory cache hits", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();
@@ -258,7 +336,7 @@ describe("tools.effective handler", () => {
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
it("coalesces identical cache misses while inventory resolution is pending", async () => {
it("coalesces identical base inventory cache misses while inventory resolution is pending", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
const second = createInvokeParams({ sessionKey: "main:abc" });
@@ -269,7 +347,7 @@ describe("tools.effective handler", () => {
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
it("returns stale cached inventory immediately while refreshing in the background", async () => {
it("returns stale cached base inventory immediately while refreshing in the background", async () => {
let now = 1_000;
testing.setToolsEffectiveNowForTest(() => now);
const stalePayload = {
@@ -334,6 +412,151 @@ describe("tools.effective handler", () => {
expect(firstRespondCall(fresh.respond)?.[1]).toBe(refreshedPayload);
});
it("reports configured MCP servers as not connected without starting them", async () => {
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValueOnce({
fingerprint: "mcp:1:test",
serverNames: ["reproProbe"],
});
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const payload = firstRespondCall(respond)?.[1] as ToolsEffectivePayload | undefined;
expect(payload?.groups?.map((group) => group.id)).toEqual(["core"]);
expect(payload?.notices?.[0]?.id).toBe("mcp-not-yet-connected");
expect(payload?.notices?.[0]?.message).toContain("reproProbe");
});
it("projects MCP tools from an already-populated session runtime catalog", async () => {
const mcpTool = makeMcpTool();
const catalog: McpToolCatalog = { version: 1, generatedAt: 1, servers: {}, tools: [] };
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValueOnce({
fingerprint: "mcp:1:test",
serverNames: ["reproProbe"],
});
runtimeMocks.peekSessionMcpRuntime.mockReturnValueOnce({
workspaceDir: "/tmp/workspace-main",
configFingerprint: "mcp:1:test",
peekCatalog: () => catalog,
});
runtimeMocks.buildBundleMcpToolsFromCatalog.mockReturnValueOnce([mcpTool]);
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const payload = firstRespondCall(respond)?.[1] as ToolsEffectivePayload | undefined;
expect(payload?.groups?.map((group) => group.id)).toEqual(["core", "mcp"]);
expect(payload?.groups?.[1]).toEqual({
id: "mcp",
label: "MCP server tools",
source: "mcp",
tools: [
{
id: "reproProbe__probe_tool",
label: "Probe Tool",
description: "Probe from MCP",
rawDescription: "Probe from MCP",
source: "mcp",
pluginId: "bundle-mcp",
},
],
});
expect(runtimeMocks.buildBundleMcpToolsFromCatalog).toHaveBeenCalledWith({
catalog,
reservedToolNames: ["exec"],
});
});
it("uses the warm runtime workspace when comparing sandboxed MCP catalogs", async () => {
const mcpTool = makeMcpTool();
const catalog: McpToolCatalog = { version: 1, generatedAt: 1, servers: {}, tools: [] };
runtimeMocks.resolveSessionMcpConfigSummary.mockImplementationOnce(
({ workspaceDir } = { workspaceDir: "" }) => ({
fingerprint: workspaceDir === "/tmp/sandbox-copy" ? "mcp:1:sandbox" : "mcp:1:workspace",
serverNames: ["reproProbe"],
}),
);
runtimeMocks.peekSessionMcpRuntime.mockReturnValueOnce({
workspaceDir: "/tmp/sandbox-copy",
configFingerprint: "mcp:1:sandbox",
peekCatalog: () => catalog,
});
runtimeMocks.buildBundleMcpToolsFromCatalog.mockReturnValueOnce([mcpTool]);
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const payload = firstRespondCall(respond)?.[1] as ToolsEffectivePayload | undefined;
expect(payload?.groups?.map((group) => group.id)).toEqual(["core", "mcp"]);
expect(runtimeMocks.resolveSessionMcpConfigSummary).toHaveBeenCalledWith({
workspaceDir: "/tmp/sandbox-copy",
cfg: {},
});
});
it("does not project warm MCP tools filtered out by final policy", async () => {
const mcpTool = makeMcpTool();
const catalog: McpToolCatalog = { version: 1, generatedAt: 1, servers: {}, tools: [] };
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValueOnce({
fingerprint: "mcp:1:test",
serverNames: ["reproProbe"],
});
runtimeMocks.peekSessionMcpRuntime.mockReturnValueOnce({
workspaceDir: "/tmp/workspace-main",
configFingerprint: "mcp:1:test",
peekCatalog: () => catalog,
});
runtimeMocks.buildBundleMcpToolsFromCatalog.mockReturnValueOnce([mcpTool]);
runtimeMocks.applyFinalEffectiveToolPolicy.mockReturnValueOnce([]);
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const payload = firstRespondCall(respond)?.[1] as ToolsEffectivePayload | undefined;
expect(payload?.groups?.map((group) => group.id)).toEqual(["core"]);
});
it("quarantines warm MCP tools with schemas the runtime cannot project", async () => {
const mcpTool = makeMcpTool({ type: "array", items: { type: "string" } });
const catalog: McpToolCatalog = { version: 1, generatedAt: 1, servers: {}, tools: [] };
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValueOnce({
fingerprint: "mcp:1:test",
serverNames: ["reproProbe"],
});
runtimeMocks.peekSessionMcpRuntime.mockReturnValueOnce({
workspaceDir: "/tmp/workspace-main",
configFingerprint: "mcp:1:test",
peekCatalog: () => catalog,
});
runtimeMocks.buildBundleMcpToolsFromCatalog.mockReturnValueOnce([mcpTool]);
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const payload = firstRespondCall(respond)?.[1] as ToolsEffectivePayload | undefined;
expect(payload?.groups?.map((group) => group.id)).toEqual(["core"]);
expect(payload?.notices?.[0]?.id).toBe("unsupported-tool-schema:reproProbe__probe_tool");
});
it("does not project stale MCP catalogs after config changes", async () => {
runtimeMocks.resolveSessionMcpConfigSummary.mockReturnValueOnce({
fingerprint: "mcp:2:test",
serverNames: ["reproProbe"],
});
runtimeMocks.peekSessionMcpRuntime.mockReturnValueOnce({
workspaceDir: "/tmp/workspace-main",
configFingerprint: "mcp:1:test",
peekCatalog: () => ({ version: 1, generatedAt: 1, servers: {}, tools: [] }),
});
const { respond, invoke } = createInvokeParams({ sessionKey: "main:abc" });
await invoke();
const payload = firstRespondCall(respond)?.[1] as ToolsEffectivePayload | undefined;
expect(payload?.groups?.map((group) => group.id)).toEqual(["core"]);
expect(payload?.notices?.[0]?.id).toBe("mcp-stale-catalog");
expect(runtimeMocks.buildBundleMcpToolsFromCatalog).not.toHaveBeenCalled();
});
it("falls back to origin.threadId when delivery context omits thread metadata", async () => {
runtimeMocks.loadSessionEntry.mockReturnValueOnce({
cfg: {},

View File

@@ -1,4 +1,11 @@
import type { EffectiveToolInventoryResult } from "../../agents/tools-effective-inventory.types.js";
import {
buildEffectiveToolInventoryGroups,
buildRuntimeCompatibleToolInventory,
} from "../../agents/tools-effective-inventory.js";
import type {
EffectiveToolInventoryNotice,
EffectiveToolInventoryResult,
} from "../../agents/tools-effective-inventory.types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logDebug, logWarn } from "../../logger.js";
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
@@ -10,17 +17,22 @@ import {
validateToolsEffectiveParams,
} from "../protocol/index.js";
import {
applyFinalEffectiveToolPolicy,
buildBundleMcpToolsFromCatalog,
deliveryContextFromSession,
getActivePluginChannelRegistryVersion,
getActivePluginRegistryVersion,
listAgentIds,
loadSessionEntry,
peekSessionMcpRuntime,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveEffectiveToolInventory,
resolveEffectiveToolInventoryRuntimeModelContext,
resolveReplyToMode,
resolveRuntimeConfigCacheKey,
resolveSessionAgentId,
resolveSessionMcpConfigSummary,
resolveSessionModelRef,
} from "./tools-effective.runtime.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
@@ -29,6 +41,7 @@ const TOOLS_EFFECTIVE_FRESH_TTL_MS = 10_000;
const TOOLS_EFFECTIVE_STALE_TTL_MS = 120_000;
const TOOLS_EFFECTIVE_SLOW_LOG_MS = 250;
const TOOLS_EFFECTIVE_CACHE_LIMIT = 128;
const MCP_CONFIG_SUMMARY_CACHE_LIMIT = 128;
let nowForToolsEffectiveCache = () => Date.now();
@@ -36,6 +49,11 @@ type TrustedToolsEffectiveContext = {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
sessionId: string;
workspaceDir: string;
runtimeConfigCacheKey: string;
pluginRegistryVersion: number;
channelRegistryVersion: number;
modelProvider?: string;
modelId?: string;
messageProvider?: string;
@@ -46,6 +64,7 @@ type TrustedToolsEffectiveContext = {
groupChannel?: string | null;
groupSpace?: string | null;
replyToMode?: "off" | "first" | "all" | "batched";
spawnedBy?: string | null;
};
type ToolsEffectiveCacheEntry = {
@@ -53,29 +72,11 @@ type ToolsEffectiveCacheEntry = {
createdAtMs: number;
};
type SessionMcpConfigSummary = ReturnType<typeof resolveSessionMcpConfigSummary>;
const toolsEffectiveCache = new Map<string, ToolsEffectiveCacheEntry>();
const toolsEffectiveInflight = new Map<string, Promise<EffectiveToolInventoryResult>>();
function resolveRequestedAgentIdOrRespondError(params: {
rawAgentId: unknown;
cfg: OpenClawConfig;
respond: RespondFn;
}) {
const knownAgents = listAgentIds(params.cfg);
const requestedAgentId = normalizeOptionalString(params.rawAgentId) ?? "";
if (!requestedAgentId) {
return undefined;
}
if (!knownAgents.includes(requestedAgentId)) {
params.respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${requestedAgentId}"`),
);
return null;
}
return requestedAgentId;
}
const mcpConfigSummaryCache = new Map<string, SessionMcpConfigSummary>();
function optionalCacheString(value: string | undefined | null): string {
return value?.trim() ?? "";
@@ -88,10 +89,14 @@ function buildToolsEffectiveCacheKey(params: {
const context = params.context;
return JSON.stringify({
v: 1,
config: resolveRuntimeConfigCacheKey(context.cfg),
pluginRegistry: getActivePluginRegistryVersion(),
channelRegistry: getActivePluginChannelRegistryVersion(),
config: context.runtimeConfigCacheKey,
pluginRegistry: context.pluginRegistryVersion,
channelRegistry: context.channelRegistryVersion,
// MCP fingerprint/server names intentionally stay out of this key: the MCP
// layer is applied after the base cache, so warm/stale runtime state alone
// never invalidates base entries.
sessionKey: params.sessionKey,
workspaceDir: optionalCacheString(context.workspaceDir),
agentId: context.agentId,
modelProvider: optionalCacheString(context.modelProvider),
modelId: optionalCacheString(context.modelId),
@@ -116,13 +121,53 @@ function trimToolsEffectiveCache(): void {
}
}
function buildMcpConfigSummaryCacheKey(params: {
context: TrustedToolsEffectiveContext;
workspaceDir: string;
}): string {
return JSON.stringify({
v: 1,
config: params.context.runtimeConfigCacheKey,
pluginRegistry: params.context.pluginRegistryVersion,
workspaceDir: params.workspaceDir,
});
}
function trimMcpConfigSummaryCache(): void {
while (mcpConfigSummaryCache.size > MCP_CONFIG_SUMMARY_CACHE_LIMIT) {
const oldest = mcpConfigSummaryCache.keys().next().value;
if (typeof oldest !== "string") {
return;
}
mcpConfigSummaryCache.delete(oldest);
}
}
function resolveCachedSessionMcpConfigSummary(params: {
context: TrustedToolsEffectiveContext;
workspaceDir: string;
}): SessionMcpConfigSummary {
const key = buildMcpConfigSummaryCacheKey(params);
const cached = mcpConfigSummaryCache.get(key);
if (cached) {
return cached;
}
const summary = resolveSessionMcpConfigSummary({
workspaceDir: params.workspaceDir,
cfg: params.context.cfg,
});
mcpConfigSummaryCache.set(key, summary);
trimMcpConfigSummaryCache();
return summary;
}
function cacheToolsEffectiveResult(key: string, value: EffectiveToolInventoryResult): void {
toolsEffectiveCache.delete(key);
toolsEffectiveCache.set(key, { value, createdAtMs: nowForToolsEffectiveCache() });
trimToolsEffectiveCache();
}
function scheduleToolsEffectiveRefresh(
function scheduleBaseToolsEffectiveRefresh(
key: string,
context: TrustedToolsEffectiveContext,
): Promise<EffectiveToolInventoryResult> {
@@ -134,32 +179,7 @@ function scheduleToolsEffectiveRefresh(
const task = new Promise<EffectiveToolInventoryResult>((resolve, reject) => {
setImmediate(() => {
try {
const agentDir = resolveAgentDir(context.cfg, context.agentId);
const runtimeModelContext = resolveEffectiveToolInventoryRuntimeModelContext({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
modelProvider: context.modelProvider,
modelId: context.modelId,
});
const value = resolveEffectiveToolInventory({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
sessionKey: context.sessionKey,
messageProvider: context.messageProvider,
modelProvider: context.modelProvider,
modelId: context.modelId,
modelApi: runtimeModelContext.modelApi,
runtimeModel: runtimeModelContext.runtimeModel,
currentChannelId: context.currentChannelId,
currentThreadTs: context.currentThreadTs,
accountId: context.accountId,
groupId: context.groupId,
groupChannel: context.groupChannel,
groupSpace: context.groupSpace,
replyToMode: context.replyToMode,
});
const value = resolveBaseToolsEffectiveInventory(context);
cacheToolsEffectiveResult(key, value);
const durationMs = nowForToolsEffectiveCache() - startedAt;
if (durationMs >= TOOLS_EFFECTIVE_SLOW_LOG_MS) {
@@ -179,16 +199,16 @@ function scheduleToolsEffectiveRefresh(
return task;
}
function refreshToolsEffectiveInBackground(
function refreshBaseToolsEffectiveInBackground(
key: string,
context: TrustedToolsEffectiveContext,
): void {
void scheduleToolsEffectiveRefresh(key, context).catch((err) => {
void scheduleBaseToolsEffectiveRefresh(key, context).catch((err) => {
logWarn(`tools-effective: background refresh failed: ${String(err)}`);
});
}
async function resolveCachedToolsEffective(params: {
async function resolveCachedBaseToolsEffective(params: {
sessionKey: string;
context: TrustedToolsEffectiveContext;
}): Promise<EffectiveToolInventoryResult> {
@@ -201,11 +221,227 @@ async function resolveCachedToolsEffective(params: {
return cached.value;
}
if (ageMs < TOOLS_EFFECTIVE_STALE_TTL_MS) {
refreshToolsEffectiveInBackground(key, params.context);
refreshBaseToolsEffectiveInBackground(key, params.context);
return cached.value;
}
}
return scheduleToolsEffectiveRefresh(key, params.context);
return scheduleBaseToolsEffectiveRefresh(key, params.context);
}
function resolveRequestedAgentIdOrRespondError(params: {
rawAgentId: unknown;
cfg: OpenClawConfig;
respond: RespondFn;
}) {
const knownAgents = listAgentIds(params.cfg);
const requestedAgentId = normalizeOptionalString(params.rawAgentId) ?? "";
if (!requestedAgentId) {
return undefined;
}
if (!knownAgents.includes(requestedAgentId)) {
params.respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${requestedAgentId}"`),
);
return null;
}
return requestedAgentId;
}
function appendMcpInventoryGroups(params: {
base: EffectiveToolInventoryResult;
mcpInventory: ReturnType<typeof buildRuntimeCompatibleToolInventory>;
}): EffectiveToolInventoryResult {
const mcpEntries = params.mcpInventory.entries.filter((entry) => entry.source === "mcp");
const notices = [...(params.base.notices ?? []), ...params.mcpInventory.notices];
const base = notices.length > 0 ? { ...params.base, notices } : params.base;
if (mcpEntries.length === 0) {
return base;
}
const mcpGroups = buildEffectiveToolInventoryGroups(mcpEntries);
return {
...base,
groups: [...base.groups, ...mcpGroups],
};
}
function appendToolInventoryNotice(
base: EffectiveToolInventoryResult,
notice: EffectiveToolInventoryNotice,
): EffectiveToolInventoryResult {
return {
...base,
notices: [...(base.notices ?? []), notice],
};
}
function formatMcpServerNames(names: readonly string[]): string {
if (names.length === 0) {
return "configured MCP servers";
}
const visible = names
.slice(0, 3)
.map((name) => `"${name}"`)
.join(", ");
return names.length > 3 ? `${visible}, and ${names.length - 3} more MCP servers` : visible;
}
function mcpDiscoveryNotice(
mcpServerNames: string[],
reason: "not-connected" | "not-listed" | "stale-config",
): EffectiveToolInventoryNotice | undefined {
if (mcpServerNames.length === 0) {
return undefined;
}
const servers = formatMcpServerNames(mcpServerNames);
switch (reason) {
case "stale-config":
return {
id: "mcp-stale-catalog",
severity: "info",
message: `MCP servers ${servers} changed since the current runtime catalog was discovered. MCP tools will appear here after the next agent run discovers them.`,
};
case "not-listed":
return {
id: "mcp-not-yet-listed",
severity: "info",
message: `MCP servers ${servers} are connected but have not finished listing tools yet. MCP tools will appear here after the session discovers them.`,
};
case "not-connected":
return {
id: "mcp-not-yet-connected",
severity: "info",
message: `MCP servers ${servers} are configured but not connected for this session yet. MCP tools will appear here after an agent run discovers them.`,
};
default:
// Exhaustiveness guard for oxlint's consistent-return rule.
return undefined;
}
}
function maybeAppendMcpNotice(
base: EffectiveToolInventoryResult,
mcpServerNames: string[],
reason: "not-connected" | "not-listed" | "stale-config",
): EffectiveToolInventoryResult {
const notice = mcpDiscoveryNotice(mcpServerNames, reason);
return notice ? appendToolInventoryNotice(base, notice) : base;
}
function resolveBaseToolsEffectiveInventory(
context: TrustedToolsEffectiveContext,
): EffectiveToolInventoryResult {
const agentDir = resolveAgentDir(context.cfg, context.agentId);
const runtimeModelContext = resolveEffectiveToolInventoryRuntimeModelContext({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
workspaceDir: context.workspaceDir,
modelProvider: context.modelProvider,
modelId: context.modelId,
});
return resolveEffectiveToolInventory({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
sessionKey: context.sessionKey,
workspaceDir: context.workspaceDir,
messageProvider: context.messageProvider,
modelProvider: context.modelProvider,
modelId: context.modelId,
modelApi: runtimeModelContext.modelApi,
runtimeModel: runtimeModelContext.runtimeModel,
currentChannelId: context.currentChannelId,
currentThreadTs: context.currentThreadTs,
accountId: context.accountId,
groupId: context.groupId,
groupChannel: context.groupChannel,
groupSpace: context.groupSpace,
replyToMode: context.replyToMode,
});
}
function filterMcpTools(params: {
context: TrustedToolsEffectiveContext;
mcpTools: Parameters<typeof applyFinalEffectiveToolPolicy>[0]["bundledTools"];
}) {
return applyFinalEffectiveToolPolicy({
bundledTools: params.mcpTools,
config: params.context.cfg,
sessionKey: params.context.sessionKey,
agentId: params.context.agentId,
modelProvider: params.context.modelProvider,
modelId: params.context.modelId,
messageProvider: params.context.messageProvider,
agentAccountId: params.context.accountId,
groupId: params.context.groupId,
groupChannel: params.context.groupChannel,
groupSpace: params.context.groupSpace,
spawnedBy: params.context.spawnedBy,
warn: logWarn,
});
}
async function resolveReadOnlyToolsEffectiveInventory(
context: TrustedToolsEffectiveContext,
): Promise<EffectiveToolInventoryResult> {
const base = await resolveCachedBaseToolsEffective({
sessionKey: context.sessionKey,
context,
});
// UI panel loads call `tools.effective`, so this path must not create MCP
// runtimes, connect transports, or issue tools/list. It only projects an
// already-warm session catalog.
const runtime = peekSessionMcpRuntime({
sessionId: context.sessionId,
sessionKey: context.sessionKey,
});
// Runtime workspaces may be sandbox copies. Compare against the same
// workspace-derived MCP summary that created the runtime, or warm sandbox
// catalogs look stale forever.
const mcpConfig = resolveCachedSessionMcpConfigSummary({
context,
workspaceDir: runtime?.workspaceDir ?? context.workspaceDir,
});
if (mcpConfig.serverNames.length === 0) {
return base;
}
if (!runtime) {
return maybeAppendMcpNotice(base, mcpConfig.serverNames, "not-connected");
}
if (runtime.configFingerprint !== mcpConfig.fingerprint) {
return maybeAppendMcpNotice(base, mcpConfig.serverNames, "stale-config");
}
// Cached catalog only; a missing catalog is a notice, not a discovery trigger.
const catalog = runtime.peekCatalog();
if (!catalog) {
return maybeAppendMcpNotice(base, mcpConfig.serverNames, "not-listed");
}
const projectedMcpTools = buildBundleMcpToolsFromCatalog({
catalog,
reservedToolNames: base.groups.flatMap((group) => group.tools.map((tool) => tool.id)),
});
const filteredMcpTools = filterMcpTools({ context, mcpTools: projectedMcpTools });
const agentDir = resolveAgentDir(context.cfg, context.agentId);
const runtimeModelContext = resolveEffectiveToolInventoryRuntimeModelContext({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
workspaceDir: runtime.workspaceDir,
modelProvider: context.modelProvider,
modelId: context.modelId,
});
const mcpInventory = buildRuntimeCompatibleToolInventory({
tools: filteredMcpTools,
cfg: context.cfg,
workspaceDir: runtime.workspaceDir,
modelProvider: context.modelProvider,
modelId: context.modelId,
modelApi: runtimeModelContext.modelApi,
runtimeModel: runtimeModelContext.runtimeModel,
});
return appendMcpInventoryGroups({ base, mcpInventory });
}
function resolveTrustedToolsEffectiveContext(params: {
@@ -241,10 +477,21 @@ function resolveTrustedToolsEffectiveContext(params: {
const delivery = deliveryContextFromSession(loaded.entry);
const resolvedModel = resolveSessionModelRef(loaded.cfg, loaded.entry, sessionAgentId);
const workspaceDir =
normalizeOptionalString(loaded.entry.spawnedWorkspaceDir) ??
resolveAgentWorkspaceDir(loaded.cfg, sessionAgentId);
const runtimeConfigCacheKey = resolveRuntimeConfigCacheKey(loaded.cfg);
const pluginRegistryVersion = getActivePluginRegistryVersion();
const channelRegistryVersion = getActivePluginChannelRegistryVersion();
return {
cfg: loaded.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
sessionId: loaded.entry.sessionId,
workspaceDir,
runtimeConfigCacheKey,
pluginRegistryVersion,
channelRegistryVersion,
modelProvider: resolvedModel.provider,
modelId: resolvedModel.model,
messageProvider:
@@ -265,6 +512,7 @@ function resolveTrustedToolsEffectiveContext(params: {
groupId: loaded.entry.groupId,
groupChannel: loaded.entry.groupChannel,
groupSpace: loaded.entry.space,
spawnedBy: normalizeOptionalString(loaded.entry.spawnedBy),
replyToMode: resolveReplyToMode(
loaded.cfg,
delivery?.channel ??
@@ -277,52 +525,57 @@ function resolveTrustedToolsEffectiveContext(params: {
};
}
async function handleToolsEffectiveRequest(params: {
rawParams: unknown;
respond: RespondFn;
context: Parameters<GatewayRequestHandlers[string]>[0]["context"];
}) {
if (!validateToolsEffectiveParams(params.rawParams)) {
params.respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid tools.effective params: ${formatValidationErrors(validateToolsEffectiveParams.errors)}`,
),
);
return;
}
const cfg = params.context.getRuntimeConfig();
const requestedAgentId = resolveRequestedAgentIdOrRespondError({
rawAgentId: params.rawParams.agentId,
cfg,
respond: params.respond,
});
if (requestedAgentId === null) {
return;
}
const trustedContext = resolveTrustedToolsEffectiveContext({
sessionKey: params.rawParams.sessionKey,
requestedAgentId,
respond: params.respond,
});
if (!trustedContext) {
return;
}
try {
params.respond(true, await resolveReadOnlyToolsEffectiveInventory(trustedContext), undefined);
} catch (err) {
params.respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `tools.effective failed: ${String(err)}`),
);
}
}
export const toolsEffectiveHandlers: GatewayRequestHandlers = {
"tools.effective": async ({ params, respond, context }) => {
if (!validateToolsEffectiveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid tools.effective params: ${formatValidationErrors(validateToolsEffectiveParams.errors)}`,
),
);
return;
}
const cfg = context.getRuntimeConfig();
const requestedAgentId = resolveRequestedAgentIdOrRespondError({
rawAgentId: params.agentId,
cfg,
await handleToolsEffectiveRequest({
rawParams: params,
respond,
context,
});
if (requestedAgentId === null) {
return;
}
const trustedContext = resolveTrustedToolsEffectiveContext({
sessionKey: params.sessionKey,
requestedAgentId,
respond,
});
if (!trustedContext) {
return;
}
try {
respond(
true,
await resolveCachedToolsEffective({
sessionKey: params.sessionKey,
context: trustedContext,
}),
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `tools.effective failed: ${String(err)}`),
);
}
},
};
@@ -330,6 +583,7 @@ export const testing = {
resetToolsEffectiveCacheForTest() {
toolsEffectiveCache.clear();
toolsEffectiveInflight.clear();
mcpConfigSummaryCache.clear();
},
setToolsEffectiveNowForTest(now: () => number) {
nowForToolsEffectiveCache = now;

View File

@@ -11,6 +11,7 @@ import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-app
type ApprovalPhase = "pending" | "resolved" | "expired";
export type ApprovalActionView = {
kind?: "command" | "decision";
decision: ExecApprovalDecision;
label: string;
style: NonNullable<InteractiveReplyButton["style"]>;

View File

@@ -1,5 +1,13 @@
import type { ExecApprovalDecision } from "./exec-approvals.js";
export type PluginApprovalActionView = {
kind?: "command" | "decision";
label: string;
command: string;
decision?: ExecApprovalDecision;
style?: "primary" | "secondary" | "success" | "danger";
};
export type PluginApprovalRequestPayload = {
pluginId?: string | null;
title: string;
@@ -8,6 +16,7 @@ export type PluginApprovalRequestPayload = {
toolName?: string | null;
toolCallId?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[] | null;
actions?: readonly PluginApprovalActionView[] | null;
agentId?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;