mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
/** Session MCP config loading, filtering, and catalog fingerprints. */
|
|
import crypto from "node:crypto";
|
|
import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js";
|
|
import type { SessionToolOverrides } from "../config/sessions/types.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { logWarn } from "../logger.js";
|
|
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
|
import { PluginLruCache } from "../plugins/plugin-cache-primitives.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 {
|
|
partitionMcpServersByConnectionScope,
|
|
redactMcpServersForFingerprint,
|
|
} from "./mcp-connection-resolver.js";
|
|
|
|
type LoadedMcpConfig = ReturnType<typeof loadEmbeddedAgentMcpConfig>;
|
|
type PreparedSessionMcpConfig = {
|
|
loaded: LoadedMcpConfig;
|
|
fingerprint: string;
|
|
};
|
|
type SessionMcpConfigDiscoveryCacheEntry = {
|
|
loaded: LoadedMcpConfig;
|
|
preparedByVariant: PluginLruCache<PreparedSessionMcpConfig>;
|
|
};
|
|
|
|
const SESSION_MCP_CONFIG_DISCOVERY_CACHE_KEY = Symbol.for(
|
|
"openclaw.sessionMcpConfigDiscoveryCache.pluginLru.v1",
|
|
);
|
|
const SESSION_MCP_CONFIG_DISCOVERY_CACHE_LIMIT = 128;
|
|
const SESSION_MCP_PREPARED_CONFIG_VARIANT_LIMIT = 64;
|
|
const EMPTY_OPENCLAW_CONFIG: OpenClawConfig = {};
|
|
|
|
type SessionMcpConfigDiscoveryCacheState = {
|
|
entries: PluginLruCache<SessionMcpConfigDiscoveryCacheEntry>;
|
|
manifestRegistryIds: WeakMap<object, number>;
|
|
nextManifestRegistryId: number;
|
|
};
|
|
|
|
function getSessionMcpConfigDiscoveryCacheState(): SessionMcpConfigDiscoveryCacheState {
|
|
return resolveGlobalSingleton(SESSION_MCP_CONFIG_DISCOVERY_CACHE_KEY, () => ({
|
|
entries: new PluginLruCache(SESSION_MCP_CONFIG_DISCOVERY_CACHE_LIMIT),
|
|
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">;
|
|
toolOverrides?: Pick<SessionToolOverrides, "mcpServers">;
|
|
}): string {
|
|
// Discovery is process-wide, so the session server overlay belongs in the key or sessions leak.
|
|
return JSON.stringify({
|
|
v: 1,
|
|
workspaceDir: params.workspaceDir,
|
|
config: resolveRuntimeConfigCacheKey(params.cfg ?? EMPTY_OPENCLAW_CONFIG),
|
|
manifestRegistry: resolveManifestRegistryCacheId(params.manifestRegistry),
|
|
mcpServers: params.toolOverrides?.mcpServers
|
|
? Object.fromEntries(
|
|
Object.entries(params.toolOverrides.mcpServers).toSorted(([left], [right]) =>
|
|
left.localeCompare(right),
|
|
),
|
|
)
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
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">;
|
|
toolOverrides?: Pick<SessionToolOverrides, "mcpServers">;
|
|
}): SessionMcpConfigDiscoveryCacheEntry {
|
|
const state = getSessionMcpConfigDiscoveryCacheState();
|
|
const key = buildSessionMcpConfigDiscoveryCacheKey(params);
|
|
const cached = state.entries.get(key);
|
|
if (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 PluginLruCache<PreparedSessionMcpConfig>(
|
|
SESSION_MCP_PREPARED_CONFIG_VARIANT_LIMIT,
|
|
),
|
|
};
|
|
// 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);
|
|
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>,
|
|
): Record<string, string> | undefined {
|
|
if (!safeServerNamesByServer || safeServerNamesByServer.size === 0) {
|
|
return undefined;
|
|
}
|
|
return Object.fromEntries(
|
|
[...safeServerNamesByServer.entries()].toSorted(([a], [b]) => a.localeCompare(b)),
|
|
);
|
|
}
|
|
|
|
function sortedSetEntries(values?: ReadonlySet<string>): string[] | undefined {
|
|
return values ? [...values].toSorted((a, b) => a.localeCompare(b)) : undefined;
|
|
}
|
|
|
|
function digestMcpToolDenials(
|
|
value?: Record<string, string[]>,
|
|
): Record<string, string[]> | undefined {
|
|
const entries = Object.entries(value ?? {})
|
|
.map(
|
|
([serverName, toolNames]) =>
|
|
[
|
|
serverName,
|
|
[...new Set(toolNames)].toSorted((left, right) => left.localeCompare(right)),
|
|
] as const,
|
|
)
|
|
.filter(([, toolNames]) => toolNames.length > 0)
|
|
.toSorted(([left], [right]) => left.localeCompare(right));
|
|
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
}
|
|
|
|
function buildPreparedConfigVariantKey(params: {
|
|
includeServerNames?: ReadonlySet<string>;
|
|
excludeServerNames?: ReadonlySet<string>;
|
|
redactConnectionServerNames?: ReadonlySet<string>;
|
|
safeServerNames?: Record<string, string>;
|
|
mcpAppsEnabled: boolean;
|
|
mcpToolsDeny?: Record<string, string[]>;
|
|
}): string {
|
|
return JSON.stringify({
|
|
include: sortedSetEntries(params.includeServerNames),
|
|
exclude: sortedSetEntries(params.excludeServerNames),
|
|
redact: sortedSetEntries(params.redactConnectionServerNames),
|
|
safeServerNames: params.safeServerNames,
|
|
mcpAppsEnabled: params.mcpAppsEnabled,
|
|
mcpToolsDeny: params.mcpToolsDeny,
|
|
});
|
|
}
|
|
|
|
function createCatalogFingerprint(params: {
|
|
servers: Record<string, unknown>;
|
|
mcpAppsEnabled: boolean;
|
|
/** Full-set server→safeName map; assignment changes must invalidate all partitions. */
|
|
safeServerNames?: Record<string, string>;
|
|
mcpToolsDeny?: Record<string, string[]>;
|
|
}): string {
|
|
// Session MCP fingerprints only invalidate in-memory runtime catalogs.
|
|
// Algorithm changes can cause one cache miss, but no persisted state migration.
|
|
// Per-user url/headers never enter this hash (see redactMcpServersForFingerprint).
|
|
return crypto.createHash("sha256").update(JSON.stringify(params)).digest("hex");
|
|
}
|
|
|
|
function filterMcpServers<T>(
|
|
mcpServers: Record<string, T>,
|
|
options?: {
|
|
includeServerNames?: ReadonlySet<string>;
|
|
excludeServerNames?: ReadonlySet<string>;
|
|
},
|
|
): Record<string, T> {
|
|
if (!options?.includeServerNames && !options?.excludeServerNames) {
|
|
return mcpServers;
|
|
}
|
|
const filtered: Record<string, T> = {};
|
|
for (const [serverName, rawServer] of Object.entries(mcpServers)) {
|
|
if (options.includeServerNames && !options.includeServerNames.has(serverName)) {
|
|
continue;
|
|
}
|
|
if (options.excludeServerNames?.has(serverName)) {
|
|
continue;
|
|
}
|
|
filtered[serverName] = rawServer;
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
export function loadSessionMcpConfig(params: {
|
|
workspaceDir: string;
|
|
cfg?: OpenClawConfig;
|
|
logDiagnostics?: boolean;
|
|
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
|
|
includeServerNames?: ReadonlySet<string>;
|
|
excludeServerNames?: ReadonlySet<string>;
|
|
/** Server names whose url/headers must not affect the fingerprint. */
|
|
redactConnectionServerNames?: ReadonlySet<string>;
|
|
/** Full-set safe-name assignments; folded into fingerprint for all partitions. */
|
|
safeServerNamesByServer?: ReadonlyMap<string, string>;
|
|
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
|
|
}): {
|
|
loaded: LoadedMcpConfig;
|
|
fingerprint: string;
|
|
} {
|
|
const discovery = loadCachedEmbeddedAgentMcpConfig({
|
|
workspaceDir: params.workspaceDir,
|
|
cfg: params.cfg,
|
|
manifestRegistry: params.manifestRegistry,
|
|
toolOverrides: params.toolOverrides,
|
|
});
|
|
if (params.logDiagnostics !== false) {
|
|
for (const diagnostic of discovery.loaded.diagnostics) {
|
|
logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
|
}
|
|
}
|
|
const safeServerNames = digestSafeServerNameAssignments(params.safeServerNamesByServer);
|
|
const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true;
|
|
const mcpToolsDeny = digestMcpToolDenials(params.toolOverrides?.mcpToolsDeny);
|
|
const variantKey = buildPreparedConfigVariantKey({
|
|
includeServerNames: params.includeServerNames,
|
|
excludeServerNames: params.excludeServerNames,
|
|
redactConnectionServerNames: params.redactConnectionServerNames,
|
|
safeServerNames,
|
|
mcpAppsEnabled,
|
|
mcpToolsDeny,
|
|
});
|
|
const prepared = discovery.preparedByVariant.get(variantKey);
|
|
if (prepared) {
|
|
return clonePreparedSessionMcpConfig(prepared);
|
|
}
|
|
const mcpServers = filterMcpServers(discovery.loaded.mcpServers, {
|
|
includeServerNames: params.includeServerNames,
|
|
excludeServerNames: params.excludeServerNames,
|
|
});
|
|
const prepareDataDirsByServer = Object.fromEntries(
|
|
Object.entries(discovery.loaded.prepareDataDirsByServer ?? {}).filter(([serverName]) =>
|
|
Object.hasOwn(mcpServers, serverName),
|
|
),
|
|
);
|
|
const fingerprintServers = params.redactConnectionServerNames?.size
|
|
? redactMcpServersForFingerprint(mcpServers, params.redactConnectionServerNames)
|
|
: mcpServers;
|
|
const result = {
|
|
loaded: {
|
|
...discovery.loaded,
|
|
mcpServers,
|
|
// Launch ownership is not serialized or fingerprinted; the injected env path already
|
|
// participates in the server fingerprint and this sidecar only authorizes mkdir.
|
|
prepareDataDirsByServer,
|
|
},
|
|
fingerprint: createCatalogFingerprint({
|
|
servers: fingerprintServers,
|
|
mcpAppsEnabled,
|
|
...(safeServerNames ? { safeServerNames } : {}),
|
|
mcpToolsDeny,
|
|
}),
|
|
};
|
|
discovery.preparedByVariant.set(variantKey, result);
|
|
return clonePreparedSessionMcpConfig(result);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
|
|
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
|
|
}): { fingerprint: string; serverNames: string[] } {
|
|
const { loaded, fingerprint } = loadSessionMcpConfig({
|
|
workspaceDir: params.workspaceDir,
|
|
cfg: params.cfg,
|
|
logDiagnostics: false,
|
|
manifestRegistry: params.manifestRegistry,
|
|
toolOverrides: params.toolOverrides,
|
|
});
|
|
const serverNames = Object.keys(loaded.mcpServers).toSorted((a, b) => a.localeCompare(b));
|
|
if (serverNames.length === 0) {
|
|
return { fingerprint, serverNames };
|
|
}
|
|
// Mirror getOrCreate: the bare-keyed runtime folds full-set safe names into
|
|
// its fingerprint and excludes requester-scoped servers from its partition.
|
|
// Compare apples-to-apples or tools.effective reports stale-config forever.
|
|
const safeServerNamesByServer = assignSafeServerNames(Object.keys(loaded.mcpServers));
|
|
const { requesterScopedServerNames } = partitionMcpServersByConnectionScope(loaded.mcpServers);
|
|
const { fingerprint: bareRuntimeFingerprint } = loadSessionMcpConfig({
|
|
workspaceDir: params.workspaceDir,
|
|
cfg: params.cfg,
|
|
logDiagnostics: false,
|
|
manifestRegistry: params.manifestRegistry,
|
|
toolOverrides: params.toolOverrides,
|
|
...(requesterScopedServerNames.length > 0
|
|
? { excludeServerNames: new Set(requesterScopedServerNames) }
|
|
: {}),
|
|
safeServerNamesByServer,
|
|
});
|
|
return { fingerprint: bareRuntimeFingerprint, serverNames };
|
|
}
|
|
|
|
/** Reads the enabled static MCP server set without opening transports or listing tools. */
|
|
export function resolveStaticSessionMcpServerNames(params: {
|
|
workspaceDir: string;
|
|
cfg?: OpenClawConfig;
|
|
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
|
|
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
|
|
}): string[] {
|
|
const { loaded } = loadSessionMcpConfig({
|
|
...params,
|
|
logDiagnostics: false,
|
|
});
|
|
const { staticServers } = partitionMcpServersByConnectionScope(loaded.mcpServers);
|
|
return Object.keys(staticServers).toSorted((left, right) => left.localeCompare(right));
|
|
}
|