import { execCli } from '@main/utils/childProcess'; import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath, getMcpConfigsBasePath, getMcpServerBasePath, } from '@main/utils/pathDecoder'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; import { resolveTeamMemberMcpScopes } from '@shared/utils/teamMemberMcpPolicy'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { McpConfigStateReader } from '../extensions/runtime/McpConfigStateReader'; import { atomicWriteAsync } from './atomicWrite'; import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types'; export interface McpLaunchSpec { command: string; args: string[]; env?: Record; } export interface McpLaunchSpecResolveProgress { phase: string; message: string; } export interface McpLaunchSpecResolveOptions { onProgress?: (progress: McpLaunchSpecResolveProgress) => void; } interface WriteMcpConfigOptions { mcpPolicy?: TeamMemberMcpPolicy; controlApiBaseUrl?: string | null; } const MCP_SERVER_NAME = 'agent-teams'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL'; const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; const MIN_MCP_NODE_MAJOR_VERSION = 20; const NODE_RUNTIME_PROBE_SCRIPT = 'process.stdout.write(JSON.stringify({execPath:process.execPath,version:process.versions.node}))'; /** * Stale configs older than this are removed on startup (best-effort). * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved * --mcp-config paths, so shorter TTLs risk deleting configs still needed * by long-running or retrying sessions in other app instances. */ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; type McpServerConfig = Record; interface NodeRuntimeProbeMetadata { path: string; version: string; } const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local']; function isPackagedApp(): boolean { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { app } = require('electron') as typeof import('electron'); return app.isPackaged; } catch { return false; } } function getAppVersion(): string { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { app } = require('electron') as typeof import('electron'); return app.getVersion(); } catch { return '0.0.0-dev'; } } /** * In a packaged Electron build the mcp-server bundle lives under * `process.resourcesPath/mcp-server/index.js` (copied via extraResources). * This is the fallback location when the stable copy is unavailable. */ function getPackagedServerEntry(): string { return path.join(process.resourcesPath, 'mcp-server', 'index.js'); } function getWorkspaceRoot(): string { return process.cwd(); } function shouldUsePackagedElectronNodeRuntime(): boolean { return ( isPackagedApp() && typeof process.execPath === 'string' && process.execPath.trim().length > 0 ); } function getPackagedElectronNodeEnv(): Record { return { [ELECTRON_RUN_AS_NODE_ENV]: '1', }; } function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec { return { command: process.execPath.trim(), args: [entry], env: getPackagedElectronNodeEnv(), }; } function getWorkspaceMcpServerDir(): string { return path.join(getWorkspaceRoot(), 'mcp-server'); } function getBuiltServerEntry(): string { return path.join(getWorkspaceMcpServerDir(), 'dist', 'index.js'); } function getSourceServerEntry(): string { return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); } function getWorkspaceTsxPackageJsonCandidates(): string[] { return [ path.join(getWorkspaceMcpServerDir(), 'node_modules', 'tsx', 'package.json'), path.join(getWorkspaceRoot(), 'node_modules', 'tsx', 'package.json'), ]; } function resolvePackageBin( packageJsonPath: string, binName: string, packageJsonRaw: string ): string | null { const packageJson = JSON.parse(packageJsonRaw) as { bin?: string | Record }; const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.[binName]; if (!bin) return null; return path.resolve(path.dirname(packageJsonPath), bin); } async function pathExists(targetPath: string): Promise { try { await fs.promises.access(targetPath, fs.constants.F_OK); return true; } catch { return false; } } async function resolveWorkspaceTsxCli(checked: string[]): Promise { for (const packageJsonPath of getWorkspaceTsxPackageJsonCandidates()) { checked.push(packageJsonPath); if (!(await pathExists(packageJsonPath))) { continue; } try { const tsxCli = resolvePackageBin( packageJsonPath, 'tsx', await fs.promises.readFile(packageJsonPath, 'utf8') ); if (!tsxCli) { logger.warn(`tsx package has no bin.tsx entry at ${packageJsonPath}`); continue; } checked.push(tsxCli); if (await pathExists(tsxCli)) { return tsxCli; } } catch (error) { logger.warn( `Failed to resolve tsx CLI from ${packageJsonPath}: ${ error instanceof Error ? error.message : String(error) }` ); } } return null; } function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean { return error.code === 'EPERM' || error.code === 'EBUSY'; } async function waitForRetry(delayMs: number): Promise { await new Promise((resolve) => setTimeout(resolve, delayMs)); } /** Check that both index.js and package.json exist in a directory. */ async function hasValidServerCopy(dir: string): Promise { return ( (await pathExists(path.join(dir, 'index.js'))) && (await pathExists(path.join(dir, 'package.json'))) ); } let _resolvedNodePath: string | undefined; let _packagedElectronNodeRuntimeProbe: { ok: true } | { ok: false; error: unknown } | undefined; export function clearResolvedNodePathForTests(): void { _resolvedNodePath = undefined; _packagedElectronNodeRuntimeProbe = undefined; } function emitProgress( options: McpLaunchSpecResolveOptions | undefined, phase: string, message: string ): void { options?.onProgress?.({ phase, message }); } function stringifyError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function looksLikeNodeBinaryPath(candidate: string | undefined): candidate is string { if (!candidate?.trim()) { return false; } return /^node(?:-\d+)?(?:\.exe)?$/i.test(path.basename(candidate.trim())); } function getNodeRuntimeCommandCandidates(): string[] { const candidates = [ process.env.NODE_BINARY, 'node', process.env.npm_node_execpath, looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined, ]; const seen = new Set(); return candidates.flatMap((candidate) => { const normalized = candidate?.trim(); if (!normalized || seen.has(normalized)) { return []; } seen.add(normalized); return [normalized]; }); } function shouldPreferShellNodeProbe(): boolean { if (process.platform === 'win32') { return false; } const pathValue = process.env.PATH?.trim(); if (!pathValue) { return true; } const minimalGuiPathEntries = new Set(['/usr/bin', '/bin', '/usr/sbin', '/sbin']); const entries = pathValue .split(path.delimiter) .map((entry) => entry.trim()) .filter(Boolean); return ( entries.length > 0 && entries.every((entry) => minimalGuiPathEntries.has(path.resolve(entry))) ); } function mergePathValues(...values: (string | undefined)[]): string | undefined { const seen = new Set(); const merged: string[] = []; for (const value of values) { if (!value) { continue; } for (const segment of value.split(path.delimiter)) { if (!segment || seen.has(segment)) { continue; } seen.add(segment); merged.push(segment); } } return merged.length > 0 ? merged.join(path.delimiter) : undefined; } function parseNodeMajorVersion(version: string): number | null { const match = /^v?(\d+)(?:\.|$)/.exec(version.trim()); if (!match) { return null; } const major = Number.parseInt(match[1] ?? '', 10); return Number.isFinite(major) ? major : null; } function parseNodeRuntimeProbeMetadata(stdout: string, command: string): NodeRuntimeProbeMetadata { const trimmed = stdout.trim(); if (!trimmed) { throw new Error(`${command} did not report Node.js runtime metadata`); } let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { throw new Error(`${command} reported invalid Node.js runtime metadata`); } if (parsed === null || typeof parsed !== 'object') { throw new Error(`${command} reported invalid Node.js runtime metadata`); } const metadata = parsed as { execPath?: unknown; version?: unknown }; const resolvedPath = typeof metadata.execPath === 'string' ? metadata.execPath.trim() : ''; if (!resolvedPath) { throw new Error(`${command} did not report process.execPath`); } const version = typeof metadata.version === 'string' ? metadata.version.trim() : ''; if (!version) { throw new Error(`${command} did not report process.versions.node`); } return { path: resolvedPath, version }; } function assertSupportedMcpNodeRuntime(command: string, metadata: NodeRuntimeProbeMetadata): void { const major = parseNodeMajorVersion(metadata.version); if (major === null || major < MIN_MCP_NODE_MAJOR_VERSION) { throw new Error( `${command} resolved ${metadata.path} with Node.js ${metadata.version}; Agent Teams MCP requires Node.js ${MIN_MCP_NODE_MAJOR_VERSION}+` ); } } function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions { return ( value !== null && typeof value === 'object' && ('mcpPolicy' in value || 'controlApiBaseUrl' in value) ); } function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, ...shellEnv, }; const mergedPath = mergePathValues(shellEnv.PATH, buildMergedCliPath(), process.env.PATH); if (mergedPath) { env.PATH = mergedPath; } return env; } async function probeNodeRuntimePath( env: NodeJS.ProcessEnv ): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> { let lastError: unknown = null; for (const command of getNodeRuntimeCommandCandidates()) { try { const { stdout } = await execCli(command, ['-e', NODE_RUNTIME_PROBE_SCRIPT], { encoding: 'utf-8', timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS, env, }); const metadata = parseNodeRuntimeProbeMetadata(stdout, command); assertSupportedMcpNodeRuntime(command, metadata); return { ok: true, path: metadata.path }; } catch (error) { lastError = error; } } return { ok: false, error: lastError ?? 'no Node.js candidates were available' }; } async function probePackagedElectronNodeRuntime( options?: McpLaunchSpecResolveOptions ): Promise<{ ok: true } | { ok: false; error: unknown }> { if (_packagedElectronNodeRuntimeProbe) { return _packagedElectronNodeRuntimeProbe; } emitProgress(options, 'electron-node-runtime', 'Checking bundled Electron Node runtime...'); try { const { stdout } = await execCli( process.execPath.trim(), ['-e', 'process.stdout.write("agent-teams-electron-node-ok")'], { encoding: 'utf-8', timeout: ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS, env: { ...process.env, ...getPackagedElectronNodeEnv(), }, } ); if (stdout.trim() !== 'agent-teams-electron-node-ok') { throw new Error('Electron Node runtime probe did not return the expected marker'); } _packagedElectronNodeRuntimeProbe = { ok: true }; } catch (error) { _packagedElectronNodeRuntimeProbe = { ok: false, error }; } return _packagedElectronNodeRuntimeProbe; } async function probeShellNodeRuntimePath( options?: McpLaunchSpecResolveOptions ): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> { let shellEnv: NodeJS.ProcessEnv = {}; try { shellEnv = await resolveInteractiveShellEnv({ source: 'mcp-node-runtime', onProgress: options?.onProgress ? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message) : undefined, }); } catch (error) { logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`); } return probeNodeRuntimePath(buildNodeResolveEnv(shellEnv)); } /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. * Uses the user's shell/enriched PATH so packaged GUI launches do not depend * on the minimal Finder/Dock PATH. */ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { if (_resolvedNodePath) return _resolvedNodePath; emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); const preferShellNodeProbe = shouldPreferShellNodeProbe(); if (preferShellNodeProbe && !process.env.NODE_BINARY?.trim()) { emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...'); const shellProbe = await probeShellNodeRuntimePath(options); if (shellProbe.ok) { _resolvedNodePath = shellProbe.path; emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); return _resolvedNodePath; } } const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({})); if (fastProbe.ok) { _resolvedNodePath = fastProbe.path; emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); return _resolvedNodePath; } if (!preferShellNodeProbe) { emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...'); const shellProbe = await probeShellNodeRuntimePath(options); if (shellProbe.ok) { _resolvedNodePath = shellProbe.path; emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); return _resolvedNodePath; } emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.'); throw new Error( `Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${ shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error) }` ); } emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.'); throw new Error( `Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${stringifyError( fastProbe.error )}` ); } /** * For packaged builds, copy the MCP server to a stable, writable location * under userData so the server runs from a non-FUSE path (fixes AppImage). * * Uses a versioned subdirectory + atomic rename to avoid partial state: * userData/mcp-server//index.js * userData/mcp-server//package.json * * Returns the resolved index.js path (stable copy or resourcesPath fallback). */ async function resolvePackagedServerEntry(options?: McpLaunchSpecResolveOptions): Promise { const fallbackEntry = getPackagedServerEntry(); if (!isPackagedApp()) return fallbackEntry; emitProgress(options, 'packaged-server', 'Checking packaged MCP server...'); const appVersion = getAppVersion(); const baseDir = getMcpServerBasePath(); const finalDir = path.join(baseDir, appVersion); const finalEntry = path.join(finalDir, 'index.js'); // Reuse existing valid copy if (await hasValidServerCopy(finalDir)) { emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...'); return finalEntry; } // Heal invalid finalDir (partial state from previous crash) try { if ((await pathExists(finalDir)) && !(await hasValidServerCopy(finalDir))) { logger.warn(`Removing invalid MCP server copy at ${finalDir}`); await fs.promises.rm(finalDir, { recursive: true, force: true }); } } catch { /* best-effort heal */ } try { const sourceDir = path.join(process.resourcesPath, 'mcp-server'); if (!(await hasValidServerCopy(sourceDir))) { logger.warn(`Packaged MCP server missing in resourcesPath: ${sourceDir}`); return fallbackEntry; } emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...'); // Atomic: copy to temp dir, then rename to final const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`); await fs.promises.mkdir(tmpDir, { recursive: true }); await fs.promises.copyFile(path.join(sourceDir, 'index.js'), path.join(tmpDir, 'index.js')); await fs.promises.copyFile( path.join(sourceDir, 'package.json'), path.join(tmpDir, 'package.json') ); try { await fs.promises.rename(tmpDir, finalDir); } catch { // finalDir appeared between our check and rename (another process won the race) await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); if (await hasValidServerCopy(finalDir)) { logger.info(`Using stable MCP server copy at ${finalDir} (concurrent copy resolved)`); return finalEntry; } // Neither our copy nor the winner's copy is valid — fallback logger.warn(`Concurrent MCP server copy failed, using resourcesPath fallback`); return fallbackEntry; } logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`); emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...'); return finalEntry; } catch (error) { logger.warn( `Failed to copy MCP server to stable path, using resourcesPath fallback: ${ error instanceof Error ? error.message : String(error) }` ); return fallbackEntry; } } export async function resolveAgentTeamsMcpLaunchSpec( options: McpLaunchSpecResolveOptions = {} ): Promise { const checked: string[] = []; // 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath if (isPackagedApp()) { const packagedEntry = await resolvePackagedServerEntry(options); checked.push(packagedEntry); if (await pathExists(packagedEntry)) { if (shouldUsePackagedElectronNodeRuntime()) { const electronProbe = await probePackagedElectronNodeRuntime(options); if (electronProbe.ok) { emitProgress( options, 'electron-node-runtime-found', 'Using bundled Electron Node runtime...' ); return buildPackagedElectronNodeLaunchSpec(packagedEntry); } logger.warn( `Bundled Electron Node runtime is unavailable for Agent Teams MCP; falling back to Node.js runtime: ${stringifyError( electronProbe.error )}` ); emitProgress( options, 'electron-node-runtime-fallback', 'Bundled Electron Node runtime unavailable, resolving Node.js fallback...' ); } return { command: await resolveNodePath(options), args: [packagedEntry], }; } logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`); } // 2. Dev mode — prefer source so pnpm dev always sees current MCP tools const sourceEntry = getSourceServerEntry(); emitProgress(options, 'source-entry', 'Checking MCP source entry...'); checked.push(sourceEntry); if (await pathExists(sourceEntry)) { emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...'); const tsxCli = await resolveWorkspaceTsxCli(checked); if (tsxCli) { return { command: await resolveNodePath(options), args: [tsxCli, sourceEntry], }; } } // 3. Dev mode fallback — use built dist when source execution is unavailable const builtEntry = getBuiltServerEntry(); emitProgress(options, 'built-entry', 'Checking built MCP server entry...'); checked.push(builtEntry); if (await pathExists(builtEntry)) { return { command: await resolveNodePath(options), args: [builtEntry], }; } throw new Error( `agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}` ); } export class TeamMcpConfigBuilder { async writeConfigFile(projectPath?: string, options?: WriteMcpConfigOptions): Promise; async writeConfigFile(projectPath?: string, mcpPolicy?: TeamMemberMcpPolicy): Promise; async writeConfigFile( projectPath?: string, optionsOrPolicy?: WriteMcpConfigOptions | TeamMemberMcpPolicy ): Promise { const launchSpec = await resolveAgentTeamsMcpLaunchSpec(); const configDir = getMcpConfigsBasePath(); const configPath = path.join( configDir, `${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json` ); const options = isWriteMcpConfigOptions(optionsOrPolicy) ? optionsOrPolicy : ({ mcpPolicy: optionsOrPolicy, } satisfies WriteMcpConfigOptions); const mcpPolicy = options.mcpPolicy; const controlApiBaseUrl = options.controlApiBaseUrl?.trim() || process.env[MCP_CONTROL_URL_ENV]?.trim() || ''; // Keep the team bootstrap config minimal: recent Claude sidechain runs can // lose the agent-teams tool surface when we inline large user MCP bundles // into the generated --mcp-config. User/project/local MCP remain loaded // through Claude's native settings sources. const generatedServers: Record = Object.create(null); generatedServers[MCP_SERVER_NAME] = { command: launchSpec.command, args: launchSpec.args, enabled: true, env: { ...launchSpec.env, [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), ...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}), }, }; if (mcpPolicy?.mode === 'strictAllowlist') { for (const [name, config] of Object.entries( await this.readAllowlistedServers(projectPath, mcpPolicy) )) { generatedServers[name] = config; } } await fs.promises.mkdir(configDir, { recursive: true }); await atomicWriteAsync( configPath, JSON.stringify( { mcpServers: generatedServers, }, null, 2 ) ); return configPath; } private async readAllowlistedServers( projectPath: string | undefined, policy: TeamMemberMcpPolicy ): Promise> { const allowlist = new Set( (policy.serverNames ?? []) .map((name) => name.trim()) .filter(Boolean) .map((name) => name.toLowerCase()) ); if (allowlist.size === 0) { return {}; } const scopes = resolveTeamMemberMcpScopes(policy); const entries = await new McpConfigStateReader().readConfigured(projectPath); const byScope = new Map(); for (const scope of MCP_CONFIG_SCOPE_PRECEDENCE) { byScope.set(scope, []); } for (const entry of entries) { if (!scopes[entry.scope]) { continue; } byScope.get(entry.scope)?.push(entry); } const selected: Record = Object.create(null); for (const scope of MCP_CONFIG_SCOPE_PRECEDENCE) { for (const entry of byScope.get(scope) ?? []) { if (entry.name.toLowerCase() === MCP_SERVER_NAME) { continue; } if (allowlist.has(entry.name.toLowerCase())) { selected[entry.name] = entry.config; } } } return selected; } /** Delete a single MCP config file (best-effort). */ async removeConfigFile(configPath: string): Promise { for (let attempt = 0; attempt <= MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) { try { await fs.promises.unlink(configPath); return; } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { return; } if ( shouldRetryMcpConfigRemoval(err) && attempt < MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length ) { await waitForRetry(MCP_CONFIG_REMOVE_RETRY_DELAYS_MS[attempt]); continue; } if (shouldRetryMcpConfigRemoval(err)) { logger.debug(`Deferred MCP config cleanup for ${configPath}: ${err.message}`); return; } logger.warn(`Failed to remove MCP config ${configPath}: ${err.message}`); return; } } } /** Remove config files owned by current process (shutdown best-effort). */ async gcOwnConfigs(): Promise { const configDir = getMcpConfigsBasePath(); const ownPrefix = `${MCP_CONFIG_PREFIX}${process.pid}-`; try { const entries = await fs.promises.readdir(configDir); await Promise.all( entries .filter((n) => n.startsWith(ownPrefix) && n.endsWith('.json')) .map((n) => fs.promises.unlink(path.join(configDir, n)).catch(() => {})) ); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== 'ENOENT') { logger.warn(`Failed to GC own MCP configs: ${err.message}`); } } } /** * Remove stale config files older than maxAgeMs (startup GC, best-effort). * Risk is reduced but not eliminated for multi-instance scenarios: * respawnAfterAuthFailure() has its own recovery to regenerate deleted configs. */ async gcStaleConfigs(maxAgeMs = MCP_CONFIG_STALE_MAX_AGE_MS): Promise { const configDir = getMcpConfigsBasePath(); try { const entries = await fs.promises.readdir(configDir); await Promise.all( entries .filter((n) => n.startsWith(MCP_CONFIG_PREFIX) && n.endsWith('.json')) .map(async (n) => { const fullPath = path.join(configDir, n); try { const stat = await fs.promises.stat(fullPath); if (Date.now() - stat.mtimeMs > maxAgeMs) { await fs.promises.unlink(fullPath); } } catch { /* ignore per-file errors */ } }) ); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== 'ENOENT') { logger.warn(`Failed to GC stale MCP configs: ${err.message}`); } } } }