import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, ThemeColor } from "@earendil-works/pi-coding-agent"; import type { McpExtensionState } from "./state.ts"; import type { ToolMetadata } from "./types.ts"; import { existsSync } from "node:fs"; import { loadMcpConfig } from "./config.ts"; import { ConsentManager } from "./consent-manager.ts"; import { McpLifecycleManager } from "./lifecycle.ts"; import { computeServerHash, getMetadataCachePath, isServerCacheValid, loadMetadataCache, reconstructToolMetadata, saveMetadataCache, serializeResources, serializeTools, type ServerCacheEntry, } from "./metadata-cache.ts"; import { McpServerManager } from "./server-manager.ts"; import { buildToolMetadata, totalToolCount } from "./tool-metadata.ts"; import { UiResourceHandler } from "./ui-resource-handler.ts"; import { openUrl, parallelLimit } from "./utils.ts"; import { logger } from "./logger.ts"; import { getMissingConfiguredDirectToolServers } from "./direct-tools.ts"; import { emit } from "../visual/event-sink.ts"; import { notifySessionRecovered, notifySessionRecoveryFailed, } from "./notify.ts"; const FAILURE_BACKOFF_MS = 60 * 1000; export function isTuiMode(ctx: Pick): boolean { return ctx.hasUI && ctx.mode === "tui"; } export async function initializeMcp( pi: ExtensionAPI, ctx: ExtensionContext ): Promise { const configPath = pi.getFlag("mcp-config") as string | undefined; const config = loadMcpConfig(configPath, ctx.cwd); const manager = new McpServerManager(); // Surface session-recovery events via the framed notification widget. // Wired only when UI is available (otherwise the callback is a no-op). if (ctx.hasUI && ctx.ui) { manager.onSessionRecover = (serverName, ok, errorMsg) => { if (ok) { notifySessionRecovered(ctx.ui as ExtensionUIContext, serverName, "tools/call"); } else { notifySessionRecoveryFailed(ctx.ui as ExtensionUIContext, serverName, errorMsg); } }; } const samplingAutoApprove = config.settings?.samplingAutoApprove === true; if (config.settings?.sampling !== false && (ctx.hasUI || samplingAutoApprove)) { manager.setSamplingConfig({ autoApprove: samplingAutoApprove, ui: ctx.hasUI ? ctx.ui : undefined, modelRegistry: ctx.modelRegistry, getCurrentModel: () => ctx.model, getSignal: () => ctx.signal, }); } const elicitationEnabled = config.settings?.elicitation !== false && ctx.hasUI; if (elicitationEnabled) { manager.setElicitationConfig({ ui: ctx.ui, allowUrl: isTuiMode(ctx), }); } const lifecycle = new McpLifecycleManager(manager); const toolMetadata = new Map(); const failureTracker = new Map(); const uiResourceHandler = new UiResourceHandler(manager); const consentManager = new ConsentManager("once-per-server"); const ui = ctx.hasUI ? ctx.ui : undefined; const state: McpExtensionState = { manager, lifecycle, toolMetadata, config, failureTracker, uiResourceHandler, consentManager, uiServer: null, completedUiSessions: [], openBrowser: (url: string) => openUrl(pi, url, process.env.BROWSER), ui, sendMessage: (message, options) => pi.sendMessage(message as unknown as Parameters[0], options), }; const serverEntries = Object.entries(config.mcpServers); if (serverEntries.length === 0) { return state; } const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10; lifecycle.setGlobalIdleTimeout(idleSetting); const cachePath = getMetadataCachePath(); const cacheFileExists = existsSync(cachePath); let cache = loadMetadataCache(); let bootstrapAll = false; if (!cacheFileExists) { bootstrapAll = true; saveMetadataCache({ version: 1, servers: {} }); } else if (!cache) { cache = { version: 1, servers: {} }; saveMetadataCache(cache); } const prefix = config.settings?.toolPrefix ?? "server"; for (const [name, definition] of serverEntries) { const lifecycleMode = definition.lifecycle ?? "lazy"; const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined); lifecycle.registerServer( name, definition, idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined ); if (lifecycleMode === "keep-alive") { lifecycle.markKeepAlive(name, definition); } if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) { const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition); toolMetadata.set(name, metadata); } } const startupServers = bootstrapAll ? serverEntries : serverEntries.filter(([, definition]) => { const mode = definition.lifecycle ?? "lazy"; return mode === "keep-alive" || mode === "eager"; }); if (ctx.hasUI && startupServers.length > 0) { ctx.ui.setStatus("mcp", `connecting ${startupServers.length} server${startupServers.length === 1 ? "" : "s"}…`); } const results = await parallelLimit(startupServers, 10, async ([name, definition]) => { try { const connection = await manager.connect(name, definition); if (connection.status === "needs-auth") { return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` }; } return { name, definition, connection, error: null }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { name, definition, connection: null, error: message }; } }); for (const { name, definition, connection, error } of results) { if (error || !connection) { if (ctx.hasUI) { emit(`MCP: Failed to connect to ${name}: ${error}`, "error"); } emit(`MCP: Failed to connect to ${name}: ${error}`, "error"); continue; } const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix); toolMetadata.set(name, metadata); updateMetadataCache(state, name); if (failedTools.length > 0 && ctx.hasUI) { emit( `MCP: ${name} - ${failedTools.length} tools skipped`, "warning" ); } } const connectedCount = results.filter(r => r.connection).length; const failedCount = results.filter(r => r.error).length; if (ctx.hasUI && connectedCount > 0) { const totalTools = totalToolCount(state); const msg = failedCount > 0 ? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)` : `MCP: ${connectedCount} servers connected (${totalTools} tools)`; emit(msg, "info"); } const envDirect = process.env.MCP_DIRECT_TOOLS; if (envDirect !== "__none__") { const currentCache = loadMetadataCache(); const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache); if (missingCacheServers.length > 0) { const bootstrapResults = await parallelLimit( missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)), 10, async (name) => { const definition = config.mcpServers[name]; try { const connection = await manager.connect(name, definition); if (connection.status === "needs-auth") { return { name, ok: false }; } const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix); toolMetadata.set(name, metadata); updateMetadataCache(state, name); return { name, ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`); return { name, ok: false }; } }, ); const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name); if (bootstrapped.length > 0 && ctx.hasUI) { emit(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info"); } } } lifecycle.setReconnectCallback((serverName) => { updateServerMetadata(state, serverName); updateMetadataCache(state, serverName); state.failureTracker.delete(serverName); updateStatusBar(state); }); lifecycle.setIdleShutdownCallback((serverName) => { const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName); logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`); updateStatusBar(state); }); lifecycle.startHealthChecks(); return state; } export function updateServerMetadata(state: McpExtensionState, serverName: string): void { const connection = state.manager.getConnection(serverName); if (!connection || connection.status !== "connected") return; const definition = state.config.mcpServers[serverName]; if (!definition) return; const prefix = state.config.settings?.toolPrefix ?? "server"; const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix); state.toolMetadata.set(serverName, metadata); } export function updateMetadataCache(state: McpExtensionState, serverName: string): void { const connection = state.manager.getConnection(serverName); if (!connection || connection.status !== "connected") return; const definition = state.config.mcpServers[serverName]; if (!definition) return; const configHash = computeServerHash(definition); const existing = loadMetadataCache(); const existingEntry = existing?.servers?.[serverName]; const tools = serializeTools(connection.tools); let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources); if ( definition.exposeResources !== false && resources.length === 0 && existingEntry?.resources?.length && existingEntry.configHash === configHash ) { resources = existingEntry.resources; } const entry: ServerCacheEntry = { configHash, tools, resources, cachedAt: Date.now(), }; saveMetadataCache({ version: 1, servers: { [serverName]: entry } }); } export function flushMetadataCache(state: McpExtensionState): void { for (const [name, connection] of state.manager.getAllConnections()) { if (connection.status === "connected") { updateMetadataCache(state, name); } } } export function updateStatusBar(state: McpExtensionState): void { const ui = state.ui; if (!ui) return; const serverNames = Object.keys(state.config.mcpServers); if (serverNames.length === 0) { ui.setStatus("mcp", undefined); return; } // Build compact per-server list: "unreal ✓ · github ✗" // Status icon reflects current state: // ✓ connected // ✗ failed (in failureTracker, with active backoff) // ⚠ needs-auth (OAuth) // ○ not connected / closed const theme = ui.theme; const fg = (color: ThemeColor, text: string): string => theme.fg(color, text); const parts: string[] = []; for (const name of serverNames) { const connection = state.manager.getConnection(name); let icon: string; let color: ThemeColor; if (connection?.status === "connected") { icon = "✓"; color = "success"; } else if (connection?.status === "needs-auth") { icon = "⚠"; color = "warning"; } else if (state.failureTracker.has(name)) { icon = "✗"; color = "error"; } else { icon = "○"; color = "dim"; } parts.push(`${name} ${fg(color, icon)}`); } // Cache stats segment — appended last so the footer stays scannable. Hidden // until the cache has actually seen traffic so a fresh session stays quiet. // ⚡ 12/3 = 12 hits, 3 misses (expirations appear inline when >0) const cacheStats = state.toolCache?.stats(); if (cacheStats && (cacheStats.hits > 0 || cacheStats.misses > 0 || cacheStats.expirations > 0 || cacheStats.size > 0)) { const exp = cacheStats.expirations > 0 ? `/${cacheStats.expirations}e` : ""; const cacheText = `⚡ ${cacheStats.hits}h/${cacheStats.misses}m${exp}`; parts.push(fg("muted", cacheText)); } ui.setStatus("mcp", parts.join(" · ")); } export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null { const failedAt = state.failureTracker.get(serverName); if (!failedAt) return null; const ageMs = Date.now() - failedAt; if (ageMs > FAILURE_BACKOFF_MS) return null; return Math.round(ageMs / 1000); } export async function lazyConnect(state: McpExtensionState, serverName: string): Promise { const connection = state.manager.getConnection(serverName); if (connection?.status === "needs-auth") { return false; } if (connection?.status === "connected") { updateServerMetadata(state, serverName); return true; } const failedAgo = getFailureAgeSeconds(state, serverName); if (failedAgo !== null) return false; const definition = state.config.mcpServers[serverName]; if (!definition) return false; try { if (state.ui) { state.ui.setStatus("mcp", `connecting ${serverName}…`); } const newConnection = await state.manager.connect(serverName, definition); if (newConnection.status === "needs-auth") { return false; } state.failureTracker.delete(serverName); updateServerMetadata(state, serverName); updateMetadataCache(state, serverName); updateStatusBar(state); return true; } catch (error) { state.failureTracker.set(serverName, Date.now()); const message = error instanceof Error ? error.message : String(error); logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`); updateStatusBar(state); return false; } } function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number { const definition = state.config.mcpServers[serverName]; if (!definition) { return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10; } if (typeof definition.idleTimeout === "number") return definition.idleTimeout; const mode = definition.lifecycle ?? "lazy"; if (mode === "eager") return 0; return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10; }