From cc9d80c3d67edb2258dbffe2f20428a4f8a9c800 Mon Sep 17 00:00:00 2001 From: Stephen Hebson Date: Fri, 17 Jul 2026 09:20:48 -0400 Subject: [PATCH] perf(mcp): cache immutable config discovery (#79882) Co-authored-by: Peter Steinberger --- .../agent-bundle-mcp-runtime-config.test.ts | 155 +++++++++++++++ src/agents/agent-bundle-mcp-runtime-config.ts | 185 +++++++++++++++++- 2 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 src/agents/agent-bundle-mcp-runtime-config.test.ts diff --git a/src/agents/agent-bundle-mcp-runtime-config.test.ts b/src/agents/agent-bundle-mcp-runtime-config.test.ts new file mode 100644 index 000000000000..f3ff936b9fbf --- /dev/null +++ b/src/agents/agent-bundle-mcp-runtime-config.test.ts @@ -0,0 +1,155 @@ +/** Tests process-wide caching for immutable bundled MCP config discovery. */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; +import { loadSessionMcpConfig } from "./agent-bundle-mcp-runtime-config.js"; + +const mocks = vi.hoisted(() => ({ + loadCount: 0, + diagnostics: [] as Array<{ pluginId: string; message: string }>, +})); + +vi.mock("./embedded-agent-mcp.js", () => ({ + loadEmbeddedAgentMcpConfig: (params: { + cfg?: { mcp?: { servers?: Record } }; + }) => { + mocks.loadCount += 1; + return { + diagnostics: structuredClone(mocks.diagnostics), + mcpServers: params.cfg?.mcp?.servers ?? {}, + }; + }, +})); + +afterEach(() => { + mocks.loadCount = 0; + mocks.diagnostics = []; + clearPluginMetadataLifecycleCaches(); +}); + +describe("session MCP config discovery cache", () => { + it("reuses immutable discovery across full and filtered catalog preparation", () => { + const cfg = { + mcp: { + servers: { + alpha: { command: "alpha" }, + beta: { command: "beta" }, + }, + }, + }; + + const full = loadSessionMcpConfig({ workspaceDir: "/reuse-workspace", cfg }); + const filtered = loadSessionMcpConfig({ + workspaceDir: "/reuse-workspace", + cfg, + includeServerNames: new Set(["alpha"]), + }); + const filteredAgain = loadSessionMcpConfig({ + workspaceDir: "/reuse-workspace", + cfg, + includeServerNames: new Set(["alpha"]), + }); + + expect(mocks.loadCount).toBe(1); + expect(filteredAgain).not.toBe(filtered); + expect(filteredAgain).toEqual(filtered); + expect(Object.keys(full.loaded.mcpServers)).toEqual(["alpha", "beta"]); + expect(Object.keys(filtered.loaded.mcpServers)).toEqual(["alpha"]); + expect(filtered.fingerprint).not.toBe(full.fingerprint); + + const alpha = filtered.loaded.mcpServers.alpha; + expect(alpha).toBeDefined(); + if (!alpha) { + throw new Error("expected filtered alpha server"); + } + alpha.command = "mutated"; + const isolated = loadSessionMcpConfig({ + workspaceDir: "/reuse-workspace", + cfg, + includeServerNames: new Set(["alpha"]), + }); + expect(isolated.loaded.mcpServers.alpha).toEqual({ command: "alpha" }); + }); + + it("invalidates discovery when config, workspace, or manifest snapshot changes", () => { + const firstConfig = { mcp: { servers: { alpha: { command: "alpha" } } } }; + const secondConfig = { mcp: { servers: { beta: { command: "beta" } } } }; + const firstRegistry = { plugins: [] }; + const secondRegistry = { plugins: [] }; + + const first = loadSessionMcpConfig({ + workspaceDir: "/workspace", + cfg: firstConfig, + manifestRegistry: firstRegistry, + }); + const second = loadSessionMcpConfig({ + workspaceDir: "/workspace", + cfg: secondConfig, + manifestRegistry: firstRegistry, + }); + loadSessionMcpConfig({ + workspaceDir: "/other-workspace", + cfg: firstConfig, + manifestRegistry: firstRegistry, + }); + loadSessionMcpConfig({ + workspaceDir: "/workspace", + cfg: firstConfig, + manifestRegistry: secondRegistry, + }); + + expect(mocks.loadCount).toBe(4); + expect(first.fingerprint).not.toBe(second.fingerprint); + }); + + it("snapshots nested config values at the cache boundary", () => { + const cfg = { + mcp: { + servers: { + alpha: { command: "alpha", args: ["original"], env: { MODE: "original" } }, + }, + }, + }; + + loadSessionMcpConfig({ workspaceDir: "/snapshot-workspace", cfg }); + cfg.mcp.servers.alpha.args[0] = "mutated"; + cfg.mcp.servers.alpha.env.MODE = "mutated"; + const isolated = loadSessionMcpConfig({ + workspaceDir: "/snapshot-workspace", + cfg: { + mcp: { + servers: { + alpha: { command: "alpha", args: ["original"], env: { MODE: "original" } }, + }, + }, + }, + }); + + expect(isolated.loaded.mcpServers.alpha).toEqual({ + command: "alpha", + args: ["original"], + env: { MODE: "original" }, + }); + }); + + it("reloads discovery after plugin metadata lifecycle invalidation", () => { + const cfg = { mcp: { servers: { alpha: { command: "alpha" } } } }; + + loadSessionMcpConfig({ workspaceDir: "/reload-workspace", cfg }); + clearPluginMetadataLifecycleCaches(); + loadSessionMcpConfig({ workspaceDir: "/reload-workspace", cfg }); + + expect(mocks.loadCount).toBe(2); + }); + + it("retries discovery after a diagnostic result", () => { + const cfg = { mcp: { servers: { alpha: { command: "alpha" } } } }; + mocks.diagnostics = [{ pluginId: "example", message: "temporary read failure" }]; + + loadSessionMcpConfig({ workspaceDir: "/retry-workspace", cfg, logDiagnostics: false }); + mocks.diagnostics = []; + loadSessionMcpConfig({ workspaceDir: "/retry-workspace", cfg, logDiagnostics: false }); + loadSessionMcpConfig({ workspaceDir: "/retry-workspace", cfg, logDiagnostics: false }); + + expect(mocks.loadCount).toBe(2); + }); +}); diff --git a/src/agents/agent-bundle-mcp-runtime-config.ts b/src/agents/agent-bundle-mcp-runtime-config.ts index 09310673165f..b72f6b697372 100644 --- a/src/agents/agent-bundle-mcp-runtime-config.ts +++ b/src/agents/agent-bundle-mcp-runtime-config.ts @@ -1,8 +1,11 @@ /** Session MCP config loading, filtering, and catalog fingerprints. */ import crypto from "node:crypto"; +import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { assignSafeServerNames } from "./agent-bundle-mcp-names.js"; import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js"; import { @@ -11,6 +14,137 @@ import { } from "./mcp-connection-resolver.js"; type LoadedMcpConfig = ReturnType; +type PreparedSessionMcpConfig = { + loaded: LoadedMcpConfig; + fingerprint: string; +}; +type SessionMcpConfigDiscoveryCacheEntry = { + loaded: LoadedMcpConfig; + preparedByVariant: Map; +}; + +const SESSION_MCP_CONFIG_DISCOVERY_CACHE_KEY = Symbol.for( + "openclaw.sessionMcpConfigDiscoveryCache", +); +const SESSION_MCP_CONFIG_DISCOVERY_CACHE_LIMIT = 128; +const SESSION_MCP_PREPARED_CONFIG_VARIANT_LIMIT = 64; +const EMPTY_OPENCLAW_CONFIG: OpenClawConfig = {}; + +type SessionMcpConfigDiscoveryCacheState = { + entries: Map; + manifestRegistryIds: WeakMap; + nextManifestRegistryId: number; +}; + +function getSessionMcpConfigDiscoveryCacheState(): SessionMcpConfigDiscoveryCacheState { + return resolveGlobalSingleton(SESSION_MCP_CONFIG_DISCOVERY_CACHE_KEY, () => ({ + entries: new Map(), + manifestRegistryIds: new WeakMap(), + nextManifestRegistryId: 1, + })); +} + +function resolveManifestRegistryCacheId( + manifestRegistry?: Pick, +): string { + if (!manifestRegistry) { + return "discovered"; + } + const state = getSessionMcpConfigDiscoveryCacheState(); + const identity = manifestRegistry.plugins; + const existing = state.manifestRegistryIds.get(identity); + if (existing !== undefined) { + return String(existing); + } + const created = state.nextManifestRegistryId; + state.nextManifestRegistryId += 1; + state.manifestRegistryIds.set(identity, created); + return String(created); +} + +function buildSessionMcpConfigDiscoveryCacheKey(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + manifestRegistry?: Pick; +}): string { + return JSON.stringify({ + v: 1, + workspaceDir: params.workspaceDir, + config: resolveRuntimeConfigCacheKey(params.cfg ?? EMPTY_OPENCLAW_CONFIG), + manifestRegistry: resolveManifestRegistryCacheId(params.manifestRegistry), + }); +} + +function trimSessionMcpConfigDiscoveryCache(state: SessionMcpConfigDiscoveryCacheState): void { + while (state.entries.size > SESSION_MCP_CONFIG_DISCOVERY_CACHE_LIMIT) { + const oldest = state.entries.keys().next().value; + if (typeof oldest !== "string") { + return; + } + state.entries.delete(oldest); + } +} + +function trimPreparedConfigVariants( + preparedByVariant: Map, +): void { + while (preparedByVariant.size > SESSION_MCP_PREPARED_CONFIG_VARIANT_LIMIT) { + const oldest = preparedByVariant.keys().next().value; + if (typeof oldest !== "string") { + return; + } + preparedByVariant.delete(oldest); + } +} + +function clonePreparedSessionMcpConfig( + prepared: PreparedSessionMcpConfig, +): PreparedSessionMcpConfig { + // Session runtimes own and may normalize their launch config. Keep cached + // preparation immutable by never exposing its object graph to a caller. + return structuredClone(prepared); +} + +function loadCachedEmbeddedAgentMcpConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + manifestRegistry?: Pick; +}): SessionMcpConfigDiscoveryCacheEntry { + const state = getSessionMcpConfigDiscoveryCacheState(); + const key = buildSessionMcpConfigDiscoveryCacheKey(params); + const cached = state.entries.get(key); + if (cached) { + // LRU order bounds long-lived processes that observe many config revisions. + state.entries.delete(key); + state.entries.set(key, cached); + return cached; + } + // Bundle manifests and their MCP JSON are process-stable metadata. Keep the + // merged discovery result warm; live clients, catalogs, and failures remain + // session-owned and are never stored here. + const discovered = structuredClone(loadEmbeddedAgentMcpConfig(params)); + const loaded = { + loaded: discovered, + preparedByVariant: new Map(), + }; + // Diagnostics can represent transient filesystem or manifest failures. Keep + // those results session-owned so the next run retries discovery. + if (discovered.diagnostics.length > 0) { + return loaded; + } + state.entries.set(key, loaded); + trimSessionMcpConfigDiscoveryCache(state); + return loaded; +} + +function clearSessionMcpConfigDiscoveryCache(): void { + const state = getSessionMcpConfigDiscoveryCacheState(); + state.entries.clear(); + state.manifestRegistryIds = new WeakMap(); + state.nextManifestRegistryId = 1; +} + +registerPluginMetadataProcessMemoLifecycleClear(clearSessionMcpConfigDiscoveryCache); function digestSafeServerNameAssignments( safeServerNamesByServer?: ReadonlyMap, @@ -23,6 +157,26 @@ function digestSafeServerNameAssignments( ); } +function sortedSetEntries(values?: ReadonlySet): string[] | undefined { + return values ? [...values].toSorted((a, b) => a.localeCompare(b)) : undefined; +} + +function buildPreparedConfigVariantKey(params: { + includeServerNames?: ReadonlySet; + excludeServerNames?: ReadonlySet; + redactConnectionServerNames?: ReadonlySet; + safeServerNames?: Record; + mcpAppsEnabled: boolean; +}): string { + return JSON.stringify({ + include: sortedSetEntries(params.includeServerNames), + exclude: sortedSetEntries(params.excludeServerNames), + redact: sortedSetEntries(params.redactConnectionServerNames), + safeServerNames: params.safeServerNames, + mcpAppsEnabled: params.mcpAppsEnabled, + }); +} + function createCatalogFingerprint(params: { servers: Record; mcpAppsEnabled: boolean; @@ -73,35 +227,52 @@ export function loadSessionMcpConfig(params: { loaded: LoadedMcpConfig; fingerprint: string; } { - const loaded = loadEmbeddedAgentMcpConfig({ + const discovery = loadCachedEmbeddedAgentMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, manifestRegistry: params.manifestRegistry, }); if (params.logDiagnostics !== false) { - for (const diagnostic of loaded.diagnostics) { + for (const diagnostic of discovery.loaded.diagnostics) { logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`); } } - const mcpServers = filterMcpServers(loaded.mcpServers, { + const safeServerNames = digestSafeServerNameAssignments(params.safeServerNamesByServer); + const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true; + const variantKey = buildPreparedConfigVariantKey({ + includeServerNames: params.includeServerNames, + excludeServerNames: params.excludeServerNames, + redactConnectionServerNames: params.redactConnectionServerNames, + safeServerNames, + mcpAppsEnabled, + }); + const prepared = discovery.preparedByVariant.get(variantKey); + if (prepared) { + discovery.preparedByVariant.delete(variantKey); + discovery.preparedByVariant.set(variantKey, prepared); + return clonePreparedSessionMcpConfig(prepared); + } + const mcpServers = filterMcpServers(discovery.loaded.mcpServers, { includeServerNames: params.includeServerNames, excludeServerNames: params.excludeServerNames, }); const fingerprintServers = params.redactConnectionServerNames?.size ? redactMcpServersForFingerprint(mcpServers, params.redactConnectionServerNames) : mcpServers; - const safeServerNames = digestSafeServerNameAssignments(params.safeServerNamesByServer); - return { + const result = { loaded: { - ...loaded, + ...discovery.loaded, mcpServers, }, fingerprint: createCatalogFingerprint({ servers: fingerprintServers, - mcpAppsEnabled: params.cfg?.mcp?.apps?.enabled === true, + mcpAppsEnabled, ...(safeServerNames ? { safeServerNames } : {}), }), }; + discovery.preparedByVariant.set(variantKey, result); + trimPreparedConfigVariants(discovery.preparedByVariant); + return clonePreparedSessionMcpConfig(result); } /**