11/** Session MCP config loading, filtering, and catalog fingerprints. */
22import crypto from "node:crypto" ;
3+ import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js" ;
34import type { OpenClawConfig } from "../config/types.openclaw.js" ;
45import { logWarn } from "../logger.js" ;
56import type { PluginManifestRegistry } from "../plugins/manifest-registry.js" ;
7+ import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js" ;
8+ import { resolveGlobalSingleton } from "../shared/global-singleton.js" ;
69import { assignSafeServerNames } from "./agent-bundle-mcp-names.js" ;
710import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js" ;
811import {
@@ -11,6 +14,137 @@ import {
1114} from "./mcp-connection-resolver.js" ;
1215
1316type 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
15149function 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+
26180function 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