mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 06:16:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user