UNPKG

pi-mcp-extension

Version:

Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.

706 lines (652 loc) • 26.2 kB
/** * Session config-reload and dynamic `mcp_servers_change` lifecycle state machine. * * Owns untrusted-safe bootstrap config loading, trust-gated `session_start` * reloads, serialized `mcp_servers_change` reconciliation, eager-start * supersession, and session teardown coordination. */ import type { ExtensionAPI, ExtensionContext, RegisteredMcpServer, } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; import { loadConfigWithOrigins, parseRegisteredServerConfig, type LoadedConfig, type McpConfig, type ServerConfig, } from "./config.js"; import { withExtensionServers, type ConfigProvenance, type ConfigProvenanceInput, } from "./server-provenance.js"; import type { ServerManager } from "./server-manager.js"; import type { ToolBridge } from "./tool-bridge.js"; import type { ResourceBridge } from "./resource-bridge.js"; import type { ConnectionCoordinator } from "./connection-coordinator.js"; import { formatErrorMessage } from "./startup-auth.js"; import { describeError } from "./debug-log-format.js"; import { getDebugLog, reportDiagnostic, setDiagnosticUi, } from "./debug-log.js"; export const DEFAULT_SETTINGS = { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5, } as const; export type SessionProvenance = ConfigProvenance; export interface BootstrapConfigPaths { bootstrapCwd?: string | undefined; globalConfigPath?: string | undefined; } export interface BootstrapConfigState { fileConfig: McpConfig; fileProvenance: ConfigProvenanceInput; extensionServers: Record<string, ServerConfig>; config: McpConfig; } export interface SessionLifecycleDeps { pi: ExtensionAPI; globalConfigPath?: string | undefined; initialState: BootstrapConfigState; manager: ServerManager; coordinator: ConnectionCoordinator; bridge: ToolBridge; resources: ResourceBridge; } function hasRegisteredServersApi( pi: ExtensionAPI, ): pi is ExtensionAPI & { getMcpServers: () => RegisteredMcpServer[] } { return typeof (pi as { getMcpServers?: () => RegisteredMcpServer[] }).getMcpServers === "function"; } function reportBootstrapConfigError(err: unknown): void { const { kind, message } = describeError(err); reportDiagnostic({ level: "error", event: "config.error", message: `Config error: ${message}`, fields: { kind }, }); } function notifySessionConfigError(ctx: ExtensionContext, err: unknown): void { const msg = formatErrorMessage(err); ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error"); } export function collectRegisteredServers( entries: readonly RegisteredMcpServer[], fileServers: Record<string, ServerConfig>, onError?: (err: unknown) => void, ): Record<string, ServerConfig> { const registered: Record<string, ServerConfig> = {}; for (const entry of entries) { // Servers configured in mcp.json take precedence over extension-registered servers. if (Object.prototype.hasOwnProperty.call(fileServers, entry.name)) { continue; } try { registered[entry.name] = parseRegisteredServerConfig( entry.name, entry.config, entry.extensionPath || "<extension>", ); } catch (err) { onError?.(err); } } return registered; } export function mergeWithRegisteredServers( fileConfig: McpConfig, registeredServers: Record<string, ServerConfig>, ): McpConfig { return { settings: fileConfig.settings, mcpServers: { ...registeredServers, ...fileConfig.mcpServers, }, }; } /** * Load only global config and extension-registered servers at bootstrap. * * WHY: cwd and project trust are available on the ExtensionContext passed to event handlers. * At bootstrap (before session_start) we load ONLY the global config so untrusted * `<cwd>/.pi/mcp.json` files are never read before `ctx.isProjectTrusted()` is checked. */ export async function loadBootstrapConfigState( pi: ExtensionAPI, paths: BootstrapConfigPaths = {}, ): Promise<BootstrapConfigState> { const bootstrapCwd = paths.bootstrapCwd ?? process.cwd(); let fileConfig: McpConfig = { settings: { ...DEFAULT_SETTINGS }, mcpServers: {}, }; // Where each configured server came from. The bootstrap load skips the project // config (trust is unknown before session_start), so this is replaced on the // first session_start with the provenance of the config actually in effect. let fileProvenance: ConfigProvenanceInput = { origins: {}, projectConfigPath: join(bootstrapCwd, ".pi", "mcp.json"), projectConfigIncluded: false, }; try { const loaded = await loadConfigWithOrigins(bootstrapCwd, paths.globalConfigPath, { includeProjectConfig: false, }); fileConfig = loaded.config; fileProvenance = loaded; } catch (err) { // Can't notify yet (no ctx), so the diagnostic goes to stderr (and to the // debug log when it is enabled). The session_start handler will re-try with // the real cwd and surface errors properly. reportBootstrapConfigError(err); } const initialEntries = hasRegisteredServersApi(pi) ? pi.getMcpServers() : []; const extensionServers = collectRegisteredServers( initialEntries, fileConfig.mcpServers, reportBootstrapConfigError, ); const config = mergeWithRegisteredServers(fileConfig, extensionServers); return { fileConfig, fileProvenance, extensionServers, config, }; } export function isProjectTrusted(ctx: ExtensionContext): boolean { return typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function" ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted() : true; } export function filterEagerServers( servers: Record<string, ServerConfig>, ): Array<[string, ServerConfig]> { return Object.entries(servers).filter(([, cfg]) => cfg.lifecycle === "eager"); } export function removeFromEagerBatch( batch: Array<[string, ServerConfig]>, name: string, ): void { const index = batch.findIndex(([entryName]) => entryName === name); if (index !== -1) { batch.splice(index, 1); } } export function abortSupersededEagerStarts( inFlightEager: ReadonlyMap<string, ServerConfig>, registeredEntries: readonly RegisteredMcpServer[], fileServers: Record<string, ServerConfig>, abortEagerStart: (name: string) => void, ): void { if (inFlightEager.size === 0) return; const nextPreview = collectRegisteredServers(registeredEntries, fileServers); for (const name of findRemovedOrChangedServers( Object.fromEntries(inFlightEager), nextPreview, )) { abortEagerStart(name); } } export function findRemovedOrChangedServers( appliedServers: Record<string, ServerConfig>, nextServers: Record<string, ServerConfig>, ): string[] { const removedOrChanged: string[] = []; for (const [name, prevCfg] of Object.entries(appliedServers)) { const nextCfg = nextServers[name]; if (!nextCfg || JSON.stringify(prevCfg) !== JSON.stringify(nextCfg)) { removedOrChanged.push(name); } } return removedOrChanged; } export function stageAddedServers( manager: ServerManager, nextServers: Record<string, ServerConfig>, ): Array<[string, ServerConfig]> { const addedEager: Array<[string, ServerConfig]> = []; for (const [name, nextCfg] of Object.entries(nextServers)) { if (manager.getServer(name)) continue; manager.setServer(name, nextCfg); if (nextCfg.lifecycle === "eager") { addedEager.push([name, nextCfg]); } } return addedEager; } /** * Coordinates `session_start`, `mcp_servers_change`, and `session_shutdown`. * * WHY: Pi's MCP registry emits `mcp_servers_change` synchronously for every register() and * unregister() (`void this.emit(...)` in the runner, which does not wait for earlier * listeners), so a burst of registrations runs concurrent reconciles. Each reconcile awaits * process teardown, so the second one sees a half-shut-down server, finishes first, and the * first one's resume then resurrects an unregistered server — or deletes a server the second * one just registered, orphaning its stdio child. One serialized queue whose turns re-read the * live registry collapses a burst into the state that is current when the turn runs. */ export class SessionLifecycle { private readonly deps: SessionLifecycleDeps; private readonly hasRegisteredServersSource: boolean; private fileConfig: McpConfig; private fileProvenance: ConfigProvenanceInput; private extensionServers: Record<string, ServerConfig>; private config: McpConfig; private latestEventEntries: RegisteredMcpServer[] = []; private sessionActive = false; private sessionEpoch = 0; // Counts every `mcp_servers_change` notification and the latest count already reconciled, // so a burst of events collapses into one turn and session_start can tell whether a // registration landed after it snapshotted the registry. private changeEventCount = 0; private appliedChangeCount = 0; private reconcileQueue: Promise<void> = Promise.resolve(); private activeSessionStart: Promise<void> = Promise.resolve(); // Eager servers currently inside a startEagerServers call, so a superseding // mcp_servers_change can abort and remove extension servers immediately, and a session // transition can stop still-connecting servers without dropping them from config. private readonly inFlightEager = new Map<string, ServerConfig>(); private readonly inFlightEagerBatch: Array<[string, ServerConfig]> = []; private readonly pendingEagerOps: Array<Promise<void>> = []; constructor(deps: SessionLifecycleDeps) { this.deps = deps; // WHY: deciding this once keeps the "can we read the live registry?" answer stable; a // reconcile turn that falls back to a stale event payload would apply superseded state. this.hasRegisteredServersSource = hasRegisteredServersApi(deps.pi); this.fileConfig = deps.initialState.fileConfig; this.fileProvenance = deps.initialState.fileProvenance; this.extensionServers = deps.initialState.extensionServers; this.config = deps.initialState.config; } getConfig(): McpConfig { return this.config; } getProvenance(): SessionProvenance { return withExtensionServers(this.fileProvenance, Object.keys(this.extensionServers)); } async handleSessionStart(ctx: ExtensionContext): Promise<void> { // WHY: a queued reconcile or earlier startup belongs to the session that started it; // marking the session inactive, bumping the epoch, stopping any in-flight eager starts, // and draining both the previous startup and reconcileQueue ensures earlier work has // settled before runSessionStart diffs sessionConfig against config. this.sessionActive = false; const epoch = ++this.sessionEpoch; this.stopInFlightEagerStarts(); const previousStart = this.activeSessionStart; const turn = this.executeSessionStartTurn(ctx, previousStart, epoch); this.activeSessionStart = turn.catch(() => { // WHY: keep activeSessionStart resolved so a later session_start or session_shutdown never rejects. }); await turn; } handleServersChange(event: unknown, ctx: ExtensionContext): Promise<void> { if (Array.isArray((event as { servers?: unknown })?.servers)) { this.latestEventEntries = (event as { servers: RegisteredMcpServer[] }).servers; } this.changeEventCount += 1; abortSupersededEagerStarts( this.inFlightEager, this.getRegisteredEntries(), this.fileConfig.mcpServers, (name) => this.abortEagerStart(name), ); return this.enqueueServerReconcile(ctx); } async handleSessionShutdown(ctx: ExtensionContext): Promise<void> { // WHY: invalidate before tearing down so an in-flight startup or queued reconcile // observes a dead session and bails, then drain both after teardown so shutdown never // returns while a turn is still mutating state or spawning children. Teardown runs first // because it aborts any eager start the in-flight turn is waiting on while keeping // configured servers in ServerManager in "stopped" state (removeServers: false). this.sessionActive = false; this.sessionEpoch += 1; this.inFlightEager.clear(); this.inFlightEagerBatch.length = 0; reportDiagnostic({ level: "info", event: "session.teardown", message: "pi-mcp session shutting down", fields: { outcome: "shutdown" }, }); await this.deps.coordinator.teardown(ctx, { removeServers: false }); await this.activeSessionStart; await this.reconcileQueue; this.deps.coordinator.setActiveContext(undefined); setDiagnosticUi(null, false); // WHY: Flush the queued debug-log writes before the process may exit, so a // bug report attached right after the session ends is not missing events. await getDebugLog()?.flush(); } private getRegisteredEntries(): RegisteredMcpServer[] { return this.hasRegisteredServersSource ? (this.deps.pi as { getMcpServers: () => RegisteredMcpServer[] }).getMcpServers() : this.latestEventEntries; } private isEpochCurrent(epoch: number): boolean { return epoch === this.sessionEpoch; } private isActiveEpoch(epoch: number): boolean { return this.sessionActive && epoch === this.sessionEpoch; } private hasPendingEpochChanges(epoch: number): boolean { return epoch === this.sessionEpoch && this.changeEventCount !== this.appliedChangeCount; } private setSessionConfigState( nextFileConfig: McpConfig, nextProvenance: ConfigProvenanceInput, nextExtensionServers: Record<string, ServerConfig>, nextConfig: McpConfig, ): void { this.fileConfig = nextFileConfig; this.fileProvenance = nextProvenance; this.appliedChangeCount = this.changeEventCount; this.extensionServers = nextExtensionServers; this.config = nextConfig; } private applyServers(next: Record<string, ServerConfig>, snapshotCount: number): void { this.extensionServers = next; this.config = mergeWithRegisteredServers(this.fileConfig, next); this.appliedChangeCount = snapshotCount; } private removeAppliedServer(name: string): void { const { [name]: _removed, ...remaining } = this.extensionServers; this.extensionServers = remaining; this.config = mergeWithRegisteredServers(this.fileConfig, remaining); } private abortEagerStart(name: string): void { this.inFlightEager.delete(name); removeFromEagerBatch(this.inFlightEagerBatch, name); this.deps.coordinator.cancelServer(name); this.deps.bridge.removeServer(name); this.deps.resources.forget(name); this.removeAppliedServer(name); this.pendingEagerOps.push(this.deps.manager.removeServer(name)); this.deps.coordinator.syncStatus(); } private stopInFlightEagerStarts(): void { this.inFlightEager.clear(); for (const [name] of this.inFlightEagerBatch.splice(0)) { this.deps.coordinator.cancelServer(name); if (this.deps.manager.getServer(name)?.state === "starting") { this.pendingEagerOps.push(this.deps.manager.stopServer(name)); } } } private async runEagerStarts( eagerServers: Array<[string, ServerConfig]>, ctx: ExtensionContext, ): Promise<void> { this.inFlightEagerBatch.splice(0, this.inFlightEagerBatch.length, ...eagerServers); for (const [name, cfg] of eagerServers) { if (Object.prototype.hasOwnProperty.call(this.extensionServers, name)) { this.inFlightEager.set(name, cfg); } } try { if (this.changeEventCount !== this.appliedChangeCount) { abortSupersededEagerStarts( this.inFlightEager, this.getRegisteredEntries(), this.fileConfig.mcpServers, (name) => this.abortEagerStart(name), ); } if (this.inFlightEagerBatch.length > 0) { await this.deps.coordinator.startEagerServers(this.inFlightEagerBatch, ctx); } } finally { this.inFlightEager.clear(); this.inFlightEagerBatch.length = 0; if (this.pendingEagerOps.length > 0) { await Promise.allSettled(this.pendingEagerOps.splice(0)); } } } private async executeSessionStartTurn( ctx: ExtensionContext, previousStart: Promise<void>, epoch: number, ): Promise<void> { await previousStart; await this.reconcileQueue; try { if (this.isEpochCurrent(epoch)) { await this.runSessionStart(ctx, epoch); } } finally { // WHY: only activate the session if neither session_shutdown nor a newer session_start // superseded this startup while runSessionStart was awaiting config I/O or eager connects. if (this.isEpochCurrent(epoch)) { this.sessionActive = true; // WHY: reconcile only when a registration landed after runSessionStart snapshotted // the registry. An unconditional turn would re-collect the registry on every session // start and repeat the per-entry config-error notification runSessionStart already // surfaced. Looping drains any registration that arrives mid-reconcile too. while (this.hasPendingEpochChanges(epoch)) { await this.enqueueServerReconcile(ctx); } } } } private applyFailedConfigFallback( ctx: ExtensionContext, includeProjectConfig: boolean, ): Array<[string, ServerConfig]> { // WHY: A config that failed to parse still supplies no server origins, so the // provenance must not keep pointing at the previous session's file entries. const fallbackProvenance: ConfigProvenanceInput = { origins: {}, projectConfigPath: join(ctx.cwd, ".pi", "mcp.json"), projectConfigIncluded: includeProjectConfig, }; const fallbackFileConfig: McpConfig = { settings: { ...DEFAULT_SETTINGS }, mcpServers: {}, }; const nextExtensionServers = collectRegisteredServers( this.getRegisteredEntries(), fallbackFileConfig.mcpServers, (regErr) => notifySessionConfigError(ctx, regErr), ); const nextConfig = mergeWithRegisteredServers(fallbackFileConfig, nextExtensionServers); this.setSessionConfigState( fallbackFileConfig, fallbackProvenance, nextExtensionServers, nextConfig, ); this.deps.bridge.updateSettings(nextConfig.settings); this.deps.manager.rebuildServers(nextConfig); return Object.keys(nextExtensionServers).length > 0 ? filterEagerServers(nextConfig.mcpServers) : []; } private hasSessionConfigChanged(nextLoaded: LoadedConfig): boolean { const previewExtensionServers = collectRegisteredServers( this.getRegisteredEntries(), nextLoaded.config.mcpServers, ); const previewConfig = mergeWithRegisteredServers( nextLoaded.config, previewExtensionServers, ); return JSON.stringify(previewConfig) !== JSON.stringify(this.config); } private commitLoadedConfig( ctx: ExtensionContext, nextLoaded: LoadedConfig, rebuild: boolean, ): Array<[string, ServerConfig]> { const nextExtensionServers = collectRegisteredServers( this.getRegisteredEntries(), nextLoaded.config.mcpServers, (err) => notifySessionConfigError(ctx, err), ); const sessionConfig = mergeWithRegisteredServers(nextLoaded.config, nextExtensionServers); this.setSessionConfigState( nextLoaded.config, nextLoaded, nextExtensionServers, rebuild ? sessionConfig : this.config, ); if (rebuild) { this.deps.bridge.updateSettings(sessionConfig.settings); this.deps.manager.rebuildServers(sessionConfig); } else { // WHY: Even when config is identical across session switches, update running servers' // cwd and emit notifications/roots/list_changed if the workspace directory changed. this.deps.manager.updateCwd(ctx.cwd); this.deps.coordinator.syncStatus(ctx); } return filterEagerServers(sessionConfig.mcpServers); } private async handleSessionConfigLoadError( ctx: ExtensionContext, includeProjectConfig: boolean, epoch: number, err: unknown, ): Promise<void> { if (!this.isEpochCurrent(epoch)) return; notifySessionConfigError(ctx, err); // WHY: Switching sessions into a directory with an invalid config must tear down stale // servers, tools, prompts, and status from the previous session rather than leaving them active. await this.deps.coordinator.teardown(ctx); if (!this.isEpochCurrent(epoch)) return; const eagerServers = this.applyFailedConfigFallback(ctx, includeProjectConfig); if (!this.isEpochCurrent(epoch) || eagerServers.length === 0) return; await this.runEagerStarts(eagerServers, ctx); } private async applyLoadedSessionConfig( ctx: ExtensionContext, nextLoaded: LoadedConfig, epoch: number, ): Promise<void> { const configChanged = this.hasSessionConfigChanged(nextLoaded); if (configChanged) { reportDiagnostic({ level: "info", event: "session.reload", message: "MCP configuration changed; reloading servers and tools", fields: { outcome: "config_changed" }, }); await this.deps.coordinator.teardown(ctx); if (!this.isEpochCurrent(epoch)) return; } const eagerServers = this.commitLoadedConfig(ctx, nextLoaded, configChanged); if (!this.isEpochCurrent(epoch) || eagerServers.length === 0) return; await this.runEagerStarts(eagerServers, ctx); } private async runSessionStart( ctx: ExtensionContext, epoch: number, ): Promise<void> { this.deps.coordinator.setActiveContext(ctx); // WHY: While a UI is active, the extension never writes to Pi's stderr — // the TUI owns that stream. Diagnostics go to the debug log instead, and // user-actionable ones are surfaced through ctx.ui.notify(...). setDiagnosticUi(ctx.ui, ctx.hasUI); reportDiagnostic({ level: "info", event: "session.started", message: `pi-mcp session started in ${ctx.cwd}`, fields: { outcome: "started" }, }); const includeProjectConfig = isProjectTrusted(ctx); let nextLoaded: LoadedConfig; try { nextLoaded = await loadConfigWithOrigins(ctx.cwd, this.deps.globalConfigPath, { includeProjectConfig, }); } catch (err) { await this.handleSessionConfigLoadError(ctx, includeProjectConfig, epoch, err); return; } if (!this.isEpochCurrent(epoch)) return; await this.applyLoadedSessionConfig(ctx, nextLoaded, epoch); } private handleReconcileError(err: unknown, epoch: number, ctx: ExtensionContext): void { const { kind, message } = describeError(err); reportDiagnostic({ level: "error", event: "servers.change_error", message: `Failed to apply MCP server registration change: ${message}`, fields: { kind }, }); if (this.isActiveEpoch(epoch)) { ctx.ui.notify(`pi-mcp: Failed to apply server registration change — ${message}`, "error"); } } private enqueueServerReconcile(ctx: ExtensionContext): Promise<void> { const epoch = this.sessionEpoch; // WHY: Pi's registry announces every register()/unregister(), including during extension // loading (before session_start has checked trust and read <cwd>/.pi/mcp.json) and after // session_shutdown. Spawning servers then attaches child processes to no session; the // built-in MCP extension ignores those events the same way (its session signal starts // aborted). session_start reads the registry itself, so the turn just no-ops instead. const turn = this.reconcileQueue.then(() => this.reconcileRegisteredServers(ctx, epoch)); // WHY: the emitter is fire-and-forget, so a rejected turn would become an unhandled // rejection; report it instead and keep the queue usable for later registrations. this.reconcileQueue = turn.catch((err: unknown) => { this.handleReconcileError(err, epoch, ctx); }); return this.reconcileQueue; } /** * Bring the manager, bridge, and config in line with the current registrations. * * `isActiveEpoch` and `changeEventCount` are checked at entry and after each removal await, so a * session switch/shutdown or a newer registration that arrived while a teardown was in flight * stops the turn before it re-adds or starts superseded servers. An eager start already running * when a newer registration or session transition arrives is aborted via `runEagerStarts`. */ private async reconcileRegisteredServers( ctx: ExtensionContext, epoch: number, ): Promise<void> { const snapshotCount = this.changeEventCount; if (!this.isActiveEpoch(epoch) || this.appliedChangeCount === snapshotCount) return; this.deps.coordinator.setActiveContext(ctx); setDiagnosticUi(ctx.ui, ctx.hasUI); const configErrors: string[] = []; const nextExtensionServers = collectRegisteredServers( this.getRegisteredEntries(), this.fileConfig.mcpServers, (err) => { configErrors.push(formatErrorMessage(err)); }, ); const removedOrChanged = findRemovedOrChangedServers( this.extensionServers, nextExtensionServers, ); for (const name of removedOrChanged) { this.deps.coordinator.cancelServer(name); this.deps.bridge.removeServer(name); this.deps.resources.forget(name); this.removeAppliedServer(name); await this.deps.manager.removeServer(name); this.deps.coordinator.syncStatus(ctx); // WHY: the next step re-adds and starts servers, which must never happen when the // session went away or a newer registration superseded this snapshot during teardown. if (!this.isActiveEpoch(epoch) || this.changeEventCount !== snapshotCount) return; } for (const msg of configErrors) { ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error"); } const addedEager = stageAddedServers(this.deps.manager, nextExtensionServers); this.applyServers(nextExtensionServers, snapshotCount); this.deps.coordinator.syncStatus(ctx); if (addedEager.length > 0) { await this.runEagerStarts(addedEager, ctx); } } }