mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
perf(mcp): cache immutable config discovery (#79882)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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<string, unknown> } };
|
||||
}) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof loadEmbeddedAgentMcpConfig>;
|
||||
type PreparedSessionMcpConfig = {
|
||||
loaded: LoadedMcpConfig;
|
||||
fingerprint: string;
|
||||
};
|
||||
type SessionMcpConfigDiscoveryCacheEntry = {
|
||||
loaded: LoadedMcpConfig;
|
||||
preparedByVariant: Map<string, PreparedSessionMcpConfig>;
|
||||
};
|
||||
|
||||
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<string, SessionMcpConfigDiscoveryCacheEntry>;
|
||||
manifestRegistryIds: WeakMap<object, number>;
|
||||
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<PluginManifestRegistry, "plugins">,
|
||||
): 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<PluginManifestRegistry, "plugins">;
|
||||
}): 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<string, PreparedSessionMcpConfig>,
|
||||
): 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<PluginManifestRegistry, "plugins">;
|
||||
}): 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<string, string>,
|
||||
@@ -23,6 +157,26 @@ function digestSafeServerNameAssignments(
|
||||
);
|
||||
}
|
||||
|
||||
function sortedSetEntries(values?: ReadonlySet<string>): string[] | undefined {
|
||||
return values ? [...values].toSorted((a, b) => a.localeCompare(b)) : undefined;
|
||||
}
|
||||
|
||||
function buildPreparedConfigVariantKey(params: {
|
||||
includeServerNames?: ReadonlySet<string>;
|
||||
excludeServerNames?: ReadonlySet<string>;
|
||||
redactConnectionServerNames?: ReadonlySet<string>;
|
||||
safeServerNames?: Record<string, string>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user