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

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));
}