Skip to content

Commit cc9d80c

Browse files
shebsonsteipete
andauthored
perf(mcp): cache immutable config discovery (#79882)
Co-authored-by: Peter Steinberger <[email protected]>
1 parent 2c43e86 commit cc9d80c

2 files changed

Lines changed: 333 additions & 7 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/** Tests process-wide caching for immutable bundled MCP config discovery. */
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
4+
import { loadSessionMcpConfig } from "./agent-bundle-mcp-runtime-config.js";
5+
6+
const mocks = vi.hoisted(() => ({
7+
loadCount: 0,
8+
diagnostics: [] as Array<{ pluginId: string; message: string }>,
9+
}));
10+
11+
vi.mock("./embedded-agent-mcp.js", () => ({
12+
loadEmbeddedAgentMcpConfig: (params: {
13+
cfg?: { mcp?: { servers?: Record<string, unknown> } };
14+
}) => {
15+
mocks.loadCount += 1;
16+
return {
17+
diagnostics: structuredClone(mocks.diagnostics),
18+
mcpServers: params.cfg?.mcp?.servers ?? {},
19+
};
20+
},
21+
}));
22+
23+
afterEach(() => {
24+
mocks.loadCount = 0;
25+
mocks.diagnostics = [];
26+
clearPluginMetadataLifecycleCaches();
27+
});
28+
29+
describe("session MCP config discovery cache", () => {
30+
it("reuses immutable discovery across full and filtered catalog preparation", () => {
31+
const cfg = {
32+
mcp: {
33+
servers: {
34+
alpha: { command: "alpha" },
35+
beta: { command: "beta" },
36+
},
37+
},
38+
};
39+
40+
const full = loadSessionMcpConfig({ workspaceDir: "/reuse-workspace", cfg });
41+
const filtered = loadSessionMcpConfig({
42+
workspaceDir: "/reuse-workspace",
43+
cfg,
44+
includeServerNames: new Set(["alpha"]),
45+
});
46+
const filteredAgain = loadSessionMcpConfig({
47+
workspaceDir: "/reuse-workspace",
48+
cfg,
49+
includeServerNames: new Set(["alpha"]),
50+
});
51+
52+
expect(mocks.loadCount).toBe(1);
53+
expect(filteredAgain).not.toBe(filtered);
54+
expect(filteredAgain).toEqual(filtered);
55+
expect(Object.keys(full.loaded.mcpServers)).toEqual(["alpha", "beta"]);
56+
expect(Object.keys(filtered.loaded.mcpServers)).toEqual(["alpha"]);
57+
expect(filtered.fingerprint).not.toBe(full.fingerprint);
58+
59+
const alpha = filtered.loaded.mcpServers.alpha;
60+
expect(alpha).toBeDefined();
61+
if (!alpha) {
62+
throw new Error("expected filtered alpha server");
63+
}
64+
alpha.command = "mutated";
65+
const isolated = loadSessionMcpConfig({
66+
workspaceDir: "/reuse-workspace",
67+
cfg,
68+
includeServerNames: new Set(["alpha"]),
69+
});
70+
expect(isolated.loaded.mcpServers.alpha).toEqual({ command: "alpha" });
71+
});
72+
73+
it("invalidates discovery when config, workspace, or manifest snapshot changes", () => {
74+
const firstConfig = { mcp: { servers: { alpha: { command: "alpha" } } } };
75+
const secondConfig = { mcp: { servers: { beta: { command: "beta" } } } };
76+
const firstRegistry = { plugins: [] };
77+
const secondRegistry = { plugins: [] };
78+
79+
const first = loadSessionMcpConfig({
80+
workspaceDir: "/workspace",
81+
cfg: firstConfig,
82+
manifestRegistry: firstRegistry,
83+
});
84+
const second = loadSessionMcpConfig({
85+
workspaceDir: "/workspace",
86+
cfg: secondConfig,
87+
manifestRegistry: firstRegistry,
88+
});
89+
loadSessionMcpConfig({
90+
workspaceDir: "/other-workspace",
91+
cfg: firstConfig,
92+
manifestRegistry: firstRegistry,
93+
});
94+
loadSessionMcpConfig({
95+
workspaceDir: "/workspace",
96+
cfg: firstConfig,
97+
manifestRegistry: secondRegistry,
98+
});
99+
100+
expect(mocks.loadCount).toBe(4);
101+
expect(first.fingerprint).not.toBe(second.fingerprint);
102+
});
103+
104+
it("snapshots nested config values at the cache boundary", () => {
105+
const cfg = {
106+
mcp: {
107+
servers: {
108+
alpha: { command: "alpha", args: ["original"], env: { MODE: "original" } },
109+
},
110+
},
111+
};
112+
113+
loadSessionMcpConfig({ workspaceDir: "/snapshot-workspace", cfg });
114+
cfg.mcp.servers.alpha.args[0] = "mutated";
115+
cfg.mcp.servers.alpha.env.MODE = "mutated";
116+
const isolated = loadSessionMcpConfig({
117+
workspaceDir: "/snapshot-workspace",
118+
cfg: {
119+
mcp: {
120+
servers: {
121+
alpha: { command: "alpha", args: ["original"], env: { MODE: "original" } },
122+
},
123+
},
124+
},
125+
});
126+
127+
expect(isolated.loaded.mcpServers.alpha).toEqual({
128+
command: "alpha",
129+
args: ["original"],
130+
env: { MODE: "original" },
131+
});
132+
});
133+
134+
it("reloads discovery after plugin metadata lifecycle invalidation", () => {
135+
const cfg = { mcp: { servers: { alpha: { command: "alpha" } } } };
136+
137+
loadSessionMcpConfig({ workspaceDir: "/reload-workspace", cfg });
138+
clearPluginMetadataLifecycleCaches();
139+
loadSessionMcpConfig({ workspaceDir: "/reload-workspace", cfg });
140+
141+
expect(mocks.loadCount).toBe(2);
142+
});
143+
144+
it("retries discovery after a diagnostic result", () => {
145+
const cfg = { mcp: { servers: { alpha: { command: "alpha" } } } };
146+
mocks.diagnostics = [{ pluginId: "example", message: "temporary read failure" }];
147+
148+
loadSessionMcpConfig({ workspaceDir: "/retry-workspace", cfg, logDiagnostics: false });
149+
mocks.diagnostics = [];
150+
loadSessionMcpConfig({ workspaceDir: "/retry-workspace", cfg, logDiagnostics: false });
151+
loadSessionMcpConfig({ workspaceDir: "/retry-workspace", cfg, logDiagnostics: false });
152+
153+
expect(mocks.loadCount).toBe(2);
154+
});
155+
});

src/agents/agent-bundle-mcp-runtime-config.ts

Lines changed: 178 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/** Session MCP config loading, filtering, and catalog fingerprints. */
22
import crypto from "node:crypto";
3+
import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js";
34
import type { OpenClawConfig } from "../config/types.openclaw.js";
45
import { logWarn } from "../logger.js";
56
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
7+
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
8+
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
69
import { assignSafeServerNames } from "./agent-bundle-mcp-names.js";
710
import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js";
811
import {
@@ -11,6 +14,137 @@ import {
1114
} from "./mcp-connection-resolver.js";
1215

1316
type LoadedMcpConfig = ReturnType<typeof loadEmbeddedAgentMcpConfig>;
17+
type PreparedSessionMcpConfig = {
18+
loaded: LoadedMcpConfig;
19+
fingerprint: string;
20+
};
21+
type SessionMcpConfigDiscoveryCacheEntry = {
22+
loaded: LoadedMcpConfig;
23+
preparedByVariant: Map<string, PreparedSessionMcpConfig>;
24+
};
25+
26+
const SESSION_MCP_CONFIG_DISCOVERY_CACHE_KEY = Symbol.for(
27+
"openclaw.sessionMcpConfigDiscoveryCache",
28+
);
29+
const SESSION_MCP_CONFIG_DISCOVERY_CACHE_LIMIT = 128;
30+
const SESSION_MCP_PREPARED_CONFIG_VARIANT_LIMIT = 64;
31+
const EMPTY_OPENCLAW_CONFIG: OpenClawConfig = {};
32+
33+
type SessionMcpConfigDiscoveryCacheState = {
34+
entries: Map<string, SessionMcpConfigDiscoveryCacheEntry>;
35+
manifestRegistryIds: WeakMap<object, number>;
36+
nextManifestRegistryId: number;
37+
};
38+
39+
function getSessionMcpConfigDiscoveryCacheState(): SessionMcpConfigDiscoveryCacheState {
40+
return resolveGlobalSingleton(SESSION_MCP_CONFIG_DISCOVERY_CACHE_KEY, () => ({
41+
entries: new Map(),
42+
manifestRegistryIds: new WeakMap(),
43+
nextManifestRegistryId: 1,
44+
}));
45+
}
46+
47+
function resolveManifestRegistryCacheId(
48+
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">,
49+
): string {
50+
if (!manifestRegistry) {
51+
return "discovered";
52+
}
53+
const state = getSessionMcpConfigDiscoveryCacheState();
54+
const identity = manifestRegistry.plugins;
55+
const existing = state.manifestRegistryIds.get(identity);
56+
if (existing !== undefined) {
57+
return String(existing);
58+
}
59+
const created = state.nextManifestRegistryId;
60+
state.nextManifestRegistryId += 1;
61+
state.manifestRegistryIds.set(identity, created);
62+
return String(created);
63+
}
64+
65+
function buildSessionMcpConfigDiscoveryCacheKey(params: {
66+
workspaceDir: string;
67+
cfg?: OpenClawConfig;
68+
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
69+
}): string {
70+
return JSON.stringify({
71+
v: 1,
72+
workspaceDir: params.workspaceDir,
73+
config: resolveRuntimeConfigCacheKey(params.cfg ?? EMPTY_OPENCLAW_CONFIG),
74+
manifestRegistry: resolveManifestRegistryCacheId(params.manifestRegistry),
75+
});
76+
}
77+
78+
function trimSessionMcpConfigDiscoveryCache(state: SessionMcpConfigDiscoveryCacheState): void {
79+
while (state.entries.size > SESSION_MCP_CONFIG_DISCOVERY_CACHE_LIMIT) {
80+
const oldest = state.entries.keys().next().value;
81+
if (typeof oldest !== "string") {
82+
return;
83+
}
84+
state.entries.delete(oldest);
85+
}
86+
}
87+
88+
function trimPreparedConfigVariants(
89+
preparedByVariant: Map<string, PreparedSessionMcpConfig>,
90+
): void {
91+
while (preparedByVariant.size > SESSION_MCP_PREPARED_CONFIG_VARIANT_LIMIT) {
92+
const oldest = preparedByVariant.keys().next().value;
93+
if (typeof oldest !== "string") {
94+
return;
95+
}
96+
preparedByVariant.delete(oldest);
97+
}
98+
}
99+
100+
function clonePreparedSessionMcpConfig(
101+
prepared: PreparedSessionMcpConfig,
102+
): PreparedSessionMcpConfig {
103+
// Session runtimes own and may normalize their launch config. Keep cached
104+
// preparation immutable by never exposing its object graph to a caller.
105+
return structuredClone(prepared);
106+
}
107+
108+
function loadCachedEmbeddedAgentMcpConfig(params: {
109+
workspaceDir: string;
110+
cfg?: OpenClawConfig;
111+
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
112+
}): SessionMcpConfigDiscoveryCacheEntry {
113+
const state = getSessionMcpConfigDiscoveryCacheState();
114+
const key = buildSessionMcpConfigDiscoveryCacheKey(params);
115+
const cached = state.entries.get(key);
116+
if (cached) {
117+
// LRU order bounds long-lived processes that observe many config revisions.
118+
state.entries.delete(key);
119+
state.entries.set(key, cached);
120+
return cached;
121+
}
122+
// Bundle manifests and their MCP JSON are process-stable metadata. Keep the
123+
// merged discovery result warm; live clients, catalogs, and failures remain
124+
// session-owned and are never stored here.
125+
const discovered = structuredClone(loadEmbeddedAgentMcpConfig(params));
126+
const loaded = {
127+
loaded: discovered,
128+
preparedByVariant: new Map(),
129+
};
130+
// Diagnostics can represent transient filesystem or manifest failures. Keep
131+
// those results session-owned so the next run retries discovery.
132+
if (discovered.diagnostics.length > 0) {
133+
return loaded;
134+
}
135+
state.entries.set(key, loaded);
136+
trimSessionMcpConfigDiscoveryCache(state);
137+
return loaded;
138+
}
139+
140+
function clearSessionMcpConfigDiscoveryCache(): void {
141+
const state = getSessionMcpConfigDiscoveryCacheState();
142+
state.entries.clear();
143+
state.manifestRegistryIds = new WeakMap();
144+
state.nextManifestRegistryId = 1;
145+
}
146+
147+
registerPluginMetadataProcessMemoLifecycleClear(clearSessionMcpConfigDiscoveryCache);
14148

15149
function digestSafeServerNameAssignments(
16150
safeServerNamesByServer?: ReadonlyMap<string, string>,
@@ -23,6 +157,26 @@ function digestSafeServerNameAssignments(
23157
);
24158
}
25159

160+
function sortedSetEntries(values?: ReadonlySet<string>): string[] | undefined {
161+
return values ? [...values].toSorted((a, b) => a.localeCompare(b)) : undefined;
162+
}
163+
164+
function buildPreparedConfigVariantKey(params: {
165+
includeServerNames?: ReadonlySet<string>;
166+
excludeServerNames?: ReadonlySet<string>;
167+
redactConnectionServerNames?: ReadonlySet<string>;
168+
safeServerNames?: Record<string, string>;
169+
mcpAppsEnabled: boolean;
170+
}): string {
171+
return JSON.stringify({
172+
include: sortedSetEntries(params.includeServerNames),
173+
exclude: sortedSetEntries(params.excludeServerNames),
174+
redact: sortedSetEntries(params.redactConnectionServerNames),
175+
safeServerNames: params.safeServerNames,
176+
mcpAppsEnabled: params.mcpAppsEnabled,
177+
});
178+
}
179+
26180
function createCatalogFingerprint(params: {
27181
servers: Record<string, unknown>;
28182
mcpAppsEnabled: boolean;
@@ -73,35 +227,52 @@ export function loadSessionMcpConfig(params: {
73227
loaded: LoadedMcpConfig;
74228
fingerprint: string;
75229
} {
76-
const loaded = loadEmbeddedAgentMcpConfig({
230+
const discovery = loadCachedEmbeddedAgentMcpConfig({
77231
workspaceDir: params.workspaceDir,
78232
cfg: params.cfg,
79233
manifestRegistry: params.manifestRegistry,
80234
});
81235
if (params.logDiagnostics !== false) {
82-
for (const diagnostic of loaded.diagnostics) {
236+
for (const diagnostic of discovery.loaded.diagnostics) {
83237
logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
84238
}
85239
}
86-
const mcpServers = filterMcpServers(loaded.mcpServers, {
240+
const safeServerNames = digestSafeServerNameAssignments(params.safeServerNamesByServer);
241+
const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true;
242+
const variantKey = buildPreparedConfigVariantKey({
243+
includeServerNames: params.includeServerNames,
244+
excludeServerNames: params.excludeServerNames,
245+
redactConnectionServerNames: params.redactConnectionServerNames,
246+
safeServerNames,
247+
mcpAppsEnabled,
248+
});
249+
const prepared = discovery.preparedByVariant.get(variantKey);
250+
if (prepared) {
251+
discovery.preparedByVariant.delete(variantKey);
252+
discovery.preparedByVariant.set(variantKey, prepared);
253+
return clonePreparedSessionMcpConfig(prepared);
254+
}
255+
const mcpServers = filterMcpServers(discovery.loaded.mcpServers, {
87256
includeServerNames: params.includeServerNames,
88257
excludeServerNames: params.excludeServerNames,
89258
});
90259
const fingerprintServers = params.redactConnectionServerNames?.size
91260
? redactMcpServersForFingerprint(mcpServers, params.redactConnectionServerNames)
92261
: mcpServers;
93-
const safeServerNames = digestSafeServerNameAssignments(params.safeServerNamesByServer);
94-
return {
262+
const result = {
95263
loaded: {
96-
...loaded,
264+
...discovery.loaded,
97265
mcpServers,
98266
},
99267
fingerprint: createCatalogFingerprint({
100268
servers: fingerprintServers,
101-
mcpAppsEnabled: params.cfg?.mcp?.apps?.enabled === true,
269+
mcpAppsEnabled,
102270
...(safeServerNames ? { safeServerNames } : {}),
103271
}),
104272
};
273+
discovery.preparedByVariant.set(variantKey, result);
274+
trimPreparedConfigVariants(discovery.preparedByVariant);
275+
return clonePreparedSessionMcpConfig(result);
105276
}
106277

107278
/**

0 commit comments

Comments
 (0)