diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 5da1dc1b35e..72cfc89e940 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -562,8 +562,14 @@ terminal summary, and sanitized error text. - `sessionKey` is required. - The gateway derives trusted runtime context from the session server-side instead of accepting caller-supplied auth or delivery context. - - The response is session-scoped and reflects what the active conversation can use right now, - including core, plugin, and channel tools. + - The response is a session-scoped server-derived projection of the active inventory, + including core, plugin, channel, and already-discovered MCP server tools. + - `tools.effective` is read-only for MCP: it may project a warm session MCP catalog through the + final tool policy, but it does not create MCP runtimes, connect transports, or issue + `tools/list`. If no matching warm catalog exists, the response may include a notice such as + `mcp-not-yet-connected`, `mcp-not-yet-listed`, or `mcp-stale-catalog`. + - Effective tool entries use `source="core"`, `source="plugin"`, `source="channel"`, or + `source="mcp"`. - Operators may call `tools.invoke` (`operator.write`) to invoke one available tool through the same gateway policy path as `/tools/invoke`. - `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 3f779970def..8b7d8d0bb56 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -60,12 +60,15 @@ Normal agent-run final answers should be durable because the embedded runtime wr ## Control UI agents tools panel - The Control UI `/agents` Tools panel has two separate views: - - **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current - session can actually use at runtime, including core, plugin, and channel-owned tools. + - **Available Right Now** uses `tools.effective(sessionKey=...)` and shows a server-derived + read-only projection of the current session inventory, including core, plugin, channel-owned, + and already-discovered MCP server tools. - **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and catalog semantics. - Runtime availability is session-scoped. Switching sessions on the same agent can change the - **Available Right Now** list. + **Available Right Now** list. If configured MCP servers have not been connected or were changed + since the last discovery, the panel shows a notice instead of silently starting MCP transports + from the read path. - The config editor does not imply runtime availability; effective access still follows policy precedence (`allow`/`deny`, per-agent and provider/channel overrides). diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index f750d128a2d..cc5224a9853 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -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; - disposeRuntime?: () => Promise; -}): Promise { - 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; + disposeRuntime?: () => Promise; +}): Promise { + 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, diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index 1cbd07f0e42..bd648121679 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -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 = 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( diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 927fd8e3661..b49e8c5362a 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -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 { await getSessionMcpRuntimeManager().disposeSession(sessionId); } diff --git a/src/agents/agent-bundle-mcp-tools.materialize.test.ts b/src/agents/agent-bundle-mcp-tools.materialize.test.ts index 6690d35f5ef..aed8db5100b 100644 --- a/src/agents/agent-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/agent-bundle-mcp-tools.materialize.test.ts @@ -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, diff --git a/src/agents/agent-bundle-mcp-tools.request-boundary.test.ts b/src/agents/agent-bundle-mcp-tools.request-boundary.test.ts index 20f66ee970e..60d21027331 100644 --- a/src/agents/agent-bundle-mcp-tools.request-boundary.test.ts +++ b/src/agents/agent-bundle-mcp-tools.request-boundary.test.ts @@ -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, diff --git a/src/agents/agent-bundle-mcp-tools.ts b/src/agents/agent-bundle-mcp-tools.ts index 69fa8762041..0f8cabae63a 100644 --- a/src/agents/agent-bundle-mcp-tools.ts +++ b/src/agents/agent-bundle-mcp-tools.ts @@ -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"; diff --git a/src/agents/agent-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts index 951e27566b1..4e389365bf9 100644 --- a/src/agents/agent-bundle-mcp-types.ts +++ b/src/agents/agent-bundle-mcp-types.ts @@ -40,7 +40,10 @@ export type SessionMcpRuntime = { lastUsedAt: number; activeLeases?: number; acquireLease?: () => () => void; + /** Lists tools if needed and may connect MCP transports. */ getCatalog: () => Promise; + /** 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; dispose: () => Promise; @@ -55,6 +58,11 @@ export type SessionMcpRuntimeManager = { }) => Promise; 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; disposeAll: () => Promise; sweepIdleRuntimes: () => Promise; diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index ba14bb291ec..27106b6bc5b 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -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: [ diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index c0290dd4267..fb7f6891c13 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -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 = 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(); + 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(); - 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 } : {}) }; } diff --git a/src/agents/tools-effective-inventory.types.ts b/src/agents/tools-effective-inventory.types.ts index 4334aa75cb1..ba170688f95 100644 --- a/src/agents/tools-effective-inventory.types.ts +++ b/src/agents/tools-effective-inventory.types.ts @@ -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; diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 88c47cc5243..83c9b7157c0 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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 }, diff --git a/src/gateway/server-methods/tools-effective.runtime.ts b/src/gateway/server-methods/tools-effective.runtime.ts index 7fc6cafa839..05bb4af96c1 100644 --- a/src/gateway/server-methods/tools-effective.runtime.ts +++ b/src/gateway/server-methods/tools-effective.runtime.ts @@ -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 { diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts index 1bcecbda783..b9d5253515b 100644 --- a/src/gateway/server-methods/tools-effective.test.ts +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -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 | 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): RespondCall | unde return respond.mock.calls[0] as RespondCall | undefined; } +function makeMcpTool(params: Record = { 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: {}, diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 76a55e68ec1..7ebe1a81c69 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -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; + const toolsEffectiveCache = new Map(); const toolsEffectiveInflight = new Map>(); - -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(); 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 { @@ -134,32 +179,7 @@ function scheduleToolsEffectiveRefresh( const task = new Promise((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 { @@ -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; +}): 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[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 { + 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[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; diff --git a/src/infra/approval-view-model.types.ts b/src/infra/approval-view-model.types.ts index c5fc2526ff1..ffcce206867 100644 --- a/src/infra/approval-view-model.types.ts +++ b/src/infra/approval-view-model.types.ts @@ -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; diff --git a/src/infra/plugin-approvals.ts b/src/infra/plugin-approvals.ts index 2d721ba42cb..52e2a735856 100644 --- a/src/infra/plugin-approvals.ts +++ b/src/infra/plugin-approvals.ts @@ -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; diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index a276eada59f..9abaca7b1e0 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -96,6 +96,21 @@ describe("agents tools panel (browser)", () => { }, ], }, + { + 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", + }, + ], + }, ], }, }), @@ -109,11 +124,16 @@ describe("agents tools panel (browser)", () => { label.textContent?.trim(), ), ).toEqual(["Available Right Now", "Quick Presets"]); - const runtimeChip = container.querySelector(".agent-tools-runtime-chip"); - expect(runtimeChip?.querySelector(".mono")?.textContent?.trim()).toBe("Message Actions"); - expect(runtimeChip?.querySelector(".agent-tools-runtime-chip__meta")?.textContent?.trim()).toBe( - "Channel: guildchat", + const runtimeChips = Array.from(container.querySelectorAll(".agent-tools-runtime-chip")).map( + (chip) => ({ + label: chip.querySelector(".mono")?.textContent?.trim(), + meta: chip.querySelector(".agent-tools-runtime-chip__meta")?.textContent?.trim(), + }), ); + expect(runtimeChips).toEqual([ + { label: "Message Actions", meta: "Channel: guildchat" }, + { label: "Probe Tool", meta: "MCP" }, + ]); expect( Array.from(container.querySelectorAll(".agent-tools-group__title > .agent-pill")).map( (pill) => pill.textContent?.trim(), @@ -151,6 +171,34 @@ describe("agents tools panel (browser)", () => { ); }); + it("renders effective tool notices", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsEffectiveResult: { + agentId: "main", + profile: "full", + groups: [], + notices: [ + { + id: "mcp-not-yet-connected", + severity: "info", + message: "MCP servers are configured but not connected yet.", + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.querySelector(".agent-tools-notices .callout.info")?.textContent?.trim()).toBe( + "MCP servers are configured but not connected yet.", + ); + }); + it("closes expanded tool rows when the parent group collapses", async () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 45b139c9b45..822be894b9b 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -175,8 +175,29 @@ function handleRuntimeToolJump(event: Event, anchorId: string) { }); } +function renderEffectiveToolNotices(result: ToolsEffectiveResult | null) { + const notices = result?.notices ?? []; + if (notices.length === 0) { + return nothing; + } + return html` +
+ ${notices.map( + (notice) => html` +
+ ${notice.message} +
+ `, + )} +
+ `; +} + function renderEffectiveToolBadge(tool: { - source: "core" | "plugin" | "channel"; + source: "core" | "plugin" | "channel" | "mcp"; pluginId?: string; channelId?: string; }) { @@ -190,6 +211,9 @@ function renderEffectiveToolBadge(tool: { ? t("agentTools.channelSource", { id: tool.channelId }) : t("agentTools.channel"); } + if (tool.source === "mcp") { + return "MCP"; + } return t("agentTools.builtIn"); } @@ -411,6 +435,7 @@ export function renderAgentTools(params: { What this agent can use in the current chat session. ${params.runtimeSessionKey || "no session"} + ${renderEffectiveToolNotices(params.toolsEffectiveResult)} ${!params.runtimeSessionMatchesSelectedAgent ? html`