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.

636 lines (579 loc) • 22.6 kB
/** * Connection and authentication coordination for MCP servers. * * Owns the concurrency-sensitive state machine governing in-flight connect * attempts, authQueue serialization, startup authorization prompts, and session * teardown. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { McpConfig, ServerConfig } from "./config.js"; import type { ManagedServer, ServerManager } from "./server-manager.js"; import type { ToolBridge } from "./tool-bridge.js"; import { McpError, AuthCancelledError, AuthRequiredError } from "./errors.js"; import { formatErrorMessage, promptStartupAuth, startupAuthNowOption, waitForQueueTurn, } from "./startup-auth.js"; export { formatErrorMessage, startupAuthLaterOption, startupAuthNowOption } from "./startup-auth.js"; export interface ConnectionCoordinatorDeps { manager: ServerManager; bridge: ToolBridge; getConfig: () => McpConfig; authorize: (serverName: string, ctx: ExtensionContext, signal: AbortSignal, reset: boolean) => Promise<void>; stopCallbackServer: () => Promise<void> | Promise<unknown>; } export interface ConnectOptions { signal?: AbortSignal | undefined; reset?: boolean | undefined; alreadyQueued?: boolean | undefined; } type ConnectAttemptEntry = { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number; }; type ActiveStartupTurn = { serverName: string; handoffPromise?: Promise<void>; }; type StartupPromptTurn = { name: string; ctx: ExtensionContext; previous: Promise<void>; promptController: AbortController; release: () => void; }; type ConnectAttemptParams = { serverName: string; serverConfig: ServerConfig; ctx: ExtensionContext; controller: AbortController; existing: ConnectAttemptEntry | undefined; reset: boolean; alreadyQueued: boolean; inheritedStartupTurn: ActiveStartupTurn | null; }; type InteractiveAuthParams = { serverName: string; ctx: ExtensionContext; controller: AbortController; reset: boolean; }; /** * Whether a declined server still deserves the `run /mcp:auth <name>` footer hint. * * WHY: A decline records "this server needed authorization at some point", which stays * true until there is positive evidence otherwise. `ready` is that evidence, and so is a * `stopped` server whose `lastError` is now a non-auth failure (`ECONNREFUSED`, timeout) — * that failure overwrote `AuthRequiredError`, so authorization is no longer the problem * and the hint would send the user down the wrong path. An unknown server drops out too. * Anything ambiguous (no `lastError` yet, still `starting`) keeps the hint, because * clearing it on doubt would hide a pending authorization the user was just told about. */ function retainsAuthHint(server: Pick<ManagedServer, "state" | "lastError"> | undefined): boolean { if (!server || server.state === "ready") return false; const lastError = server.lastError; if (!lastError || lastError instanceof AuthRequiredError) return true; return server.state !== "stopped"; } export class ConnectionCoordinator { private readonly deps: ConnectionCoordinatorDeps; private readonly connectAttempts = new Map<string, ConnectAttemptEntry>(); private readonly declinedStartupAuth = new Set<string>(); private readonly startupPromptControllers = new Map<string, AbortController>(); private authQueue: Promise<void> = Promise.resolve(); private activeStartupTurn: ActiveStartupTurn | null = null; private activeCtx: ExtensionContext | undefined; constructor(deps: ConnectionCoordinatorDeps) { this.deps = deps; } setActiveContext(ctx: ExtensionContext | undefined): void { this.activeCtx = ctx; } hasConnectAttempt(serverName: string): boolean { return this.connectAttempts.has(serverName); } isStartupAuthDeclined(serverName: string): boolean { return this.declinedStartupAuth.has(serverName); } get inFlightAttemptCount(): number { return this.connectAttempts.size; } private async runSerializedAuth( serverName: string, signal: AbortSignal, fn: () => Promise<void>, ): Promise<void> { const previous = this.authQueue; let release!: () => void; const gate = new Promise<void>((resolve) => { release = resolve; }); this.authQueue = previous.then(() => gate); try { await waitForQueueTurn(previous, signal, serverName); await fn(); } finally { release(); } } syncStatus(ctx: ExtensionContext | undefined = this.activeCtx): void { for (const declinedName of Array.from(this.declinedStartupAuth)) { const server = this.deps.manager.getServer(declinedName); if (!retainsAuthHint(server)) { this.declinedStartupAuth.delete(declinedName); } } if (!ctx) return; if (this.declinedStartupAuth.size === 0) { ctx.ui.setStatus?.("pi-mcp", undefined); return; } // WHY: Display the most recent remaining server that still needs authorization so resolving // one server does not clear the footer hint while other declined servers are still pending. const remaining = Array.from(this.declinedStartupAuth); const displayServer = remaining[remaining.length - 1]!; ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${displayServer}`); } markAuthRequired(serverName: string, ctx: ExtensionContext | undefined = this.activeCtx): void { this.declinedStartupAuth.add(serverName); this.syncStatus(ctx); } handleRecoveryFailed(serverName: string, error: unknown): void { if (error instanceof AuthRequiredError) { this.activeCtx?.ui.notify( `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`, "warning", ); // WHY: Route through markAuthRequired so a mid-session 401 (reported by // ServerManager.handleUnauthorizedServerError) lands in the declined set and // repaints the footer hint exactly like a declined startup prompt. this.markAuthRequired(serverName); return; } this.activeCtx?.ui.notify( `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`, "error", ); // WHY: The failure just overwrote lastError, so a hint left over from an earlier // decline now points at authorization that is not the problem — clear it. this.syncStatus(this.activeCtx); } cancelServer(serverName: string): void { this.declinedStartupAuth.delete(serverName); this.startupPromptControllers.get(serverName)?.abort(); this.startupPromptControllers.delete(serverName); this.connectAttempts.get(serverName)?.controller.abort(); } async teardown(ctx?: ExtensionContext, options?: { removeServers?: boolean }): Promise<void> { const removeServers = options?.removeServers ?? true; const targetCtx = ctx ?? this.activeCtx; this.declinedStartupAuth.clear(); targetCtx?.ui.setStatus?.("pi-mcp", undefined); for (const controller of this.startupPromptControllers.values()) { controller.abort(); } this.startupPromptControllers.clear(); const inFlightPromises = Array.from(this.connectAttempts.values(), (attempt) => attempt.promise); for (const attempt of this.connectAttempts.values()) { attempt.controller.abort(); } this.connectAttempts.clear(); if (inFlightPromises.length > 0) { await Promise.allSettled(inFlightPromises); } await this.deps.stopCallbackServer().catch(() => { // WHY: Ignore callback server shutdown errors during session teardown. }); for (const server of this.deps.manager.getAllServers()) { if (removeServers) { this.deps.bridge.removeServer(server.name); } else { this.deps.bridge.deactivateServer(server.name); } } await this.deps.manager.shutdownAll(); } enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void { // WHY: Deduplicate startup prompts when session_start fires multiple times or while a prompt // for the same server is already queued or active. if (this.startupPromptControllers.has(name)) { return; } const promptController = new AbortController(); // WHY: Register the AbortController immediately when queued (before awaiting authQueue) so // /mcp:stop, /mcp:auth, or session teardown can cancel prompts that are still waiting in line. this.startupPromptControllers.set(name, promptController); const previous = this.authQueue; let release!: () => void; const gate = new Promise<void>((resolve) => { release = resolve; }); this.authQueue = previous.then(() => gate); void this._runStartupPromptTurn({ name, ctx, previous, promptController, release }); } private async _runStartupPromptTurn(turn: StartupPromptTurn): Promise<void> { let currentTurn: ActiveStartupTurn | null = null; try { await waitForQueueTurn(turn.previous, turn.promptController.signal, turn.name); if (this._shouldSkipStartupPrompt(turn)) return; currentTurn = { serverName: turn.name }; this.activeStartupTurn = currentTurn; const choice = await this._promptStartupChoice(turn); if (await this._awaitHandoffIfSuperseded(currentTurn)) return; if (this._isPromptAbandoned(turn)) return; await this._applyStartupAuthChoice(turn, choice); } catch (err) { this._notifyStartupFailure(turn.name, turn.ctx, err); } finally { await this._finishStartupTurn(turn, currentTurn); } } private async _promptStartupChoice(turn: StartupPromptTurn): Promise<string | undefined> { try { return await promptStartupAuth(turn.name, turn.ctx, turn.promptController.signal); } finally { this._forgetPromptController(turn); } } private _shouldSkipStartupPrompt(turn: StartupPromptTurn): boolean { return this._isPromptAbandoned(turn) || this.declinedStartupAuth.has(turn.name); } private _isPromptAbandoned(turn: StartupPromptTurn): boolean { return ( turn.promptController.signal.aborted || this.deps.manager.getServer(turn.name)?.state === "ready" || this.connectAttempts.has(turn.name) ); } private async _awaitHandoffIfSuperseded(turn: ActiveStartupTurn): Promise<boolean> { if (!turn.handoffPromise) return false; // WHY: An explicit connectRequested (/mcp:auth, /mcp:start, or mcp_connect) superseded // this server's startup prompt while it held the active queue turn. Hold the queue slot // until that connection/auth finishes before letting the next server's prompt appear. await turn.handoffPromise.catch(() => { // WHY: Errors from an explicit connectRequested handoff are surfaced to its own caller. }); return true; } private async _applyStartupAuthChoice( turn: StartupPromptTurn, choice: string | undefined, ): Promise<void> { if (choice !== startupAuthNowOption) { this.declinedStartupAuth.add(turn.name); this.syncStatus(turn.ctx); return; } try { await this.connect(turn.name, turn.ctx, { alreadyQueued: true }); } catch (err) { this._notifyStartupFailure(turn.name, turn.ctx, err); } } private _notifyStartupFailure(name: string, ctx: ExtensionContext, err: unknown): void { if (err instanceof AuthCancelledError) return; const msg = formatErrorMessage(err); ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error"); } private _forgetPromptController(turn: StartupPromptTurn): void { if (this.startupPromptControllers.get(turn.name) === turn.promptController) { this.startupPromptControllers.delete(turn.name); } } private async _finishStartupTurn( turn: StartupPromptTurn, currentTurn: ActiveStartupTurn | null, ): Promise<void> { this._forgetPromptController(turn); if (currentTurn && this.activeStartupTurn === currentTurn) { if (currentTurn.handoffPromise) { await currentTurn.handoffPromise.catch(() => { // WHY: Suppress rejection here because connectRequested caller handles it. }); } this.activeStartupTurn = null; } turn.release(); } private async _startEagerServer( name: string, cfg: ServerConfig, ctx: ExtensionContext, authRequiredServers: Set<string>, ): Promise<void> { try { await this.deps.manager.startServer(name, ctx.cwd); } catch (err) { if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) { authRequiredServers.add(name); return; } // WHY: A cancelled start means the user stopped the server (/mcp:stop) or the // session is tearing down; _notifyStartupFailure ignores AuthCancelledError so // it is not reported as a bogus start failure. this._notifyStartupFailure(name, ctx, err); } } async startEagerServers( serversToStart: Array<[string, ServerConfig]>, ctx: ExtensionContext, ): Promise<void> { this.activeCtx = ctx; const authRequiredServers = new Set<string>(); // Start all eager servers concurrently await Promise.allSettled( serversToStart.map(([name, cfg]) => this._startEagerServer(name, cfg, ctx, authRequiredServers)), ); // WHY: Reconcile the footer hint once every eager start has settled — a declined // server may have connected cleanly or failed with a non-auth error, and either // outcome makes "run /mcp:auth <name>" stale. this.syncStatus(ctx); for (const [name] of serversToStart) { if (!authRequiredServers.has(name)) continue; if (this.declinedStartupAuth.has(name)) continue; this.enqueueStartupAuthPrompt(name, ctx); } for (let i = 0; i < 4 * authRequiredServers.size; i++) { await Promise.resolve(); } } private async _waitForConnectAttempt( serverName: string, entry: ConnectAttemptEntry, signal?: AbortSignal, ): Promise<void> { entry.waiters++; let released = false; const releaseWaiter = () => { if (!released) { released = true; entry.waiters--; } }; if (!signal) { try { return await entry.promise; } finally { releaseWaiter(); } } let stopPromise: Promise<unknown> | undefined; let onAbort!: () => void; const aborted = new Promise<never>((_resolve, reject) => { onAbort = () => { releaseWaiter(); if (entry.waiters === 0) { if (this.connectAttempts.get(serverName) === entry) { this.connectAttempts.delete(serverName); } entry.controller.abort(); stopPromise = this.deps.manager.stopServer(serverName); void stopPromise.finally(() => reject(new AuthCancelledError(serverName))); } else { reject(new AuthCancelledError(serverName)); } }; signal.addEventListener("abort", onAbort, { once: true }); }); try { return await Promise.race([entry.promise, aborted]); } finally { signal.removeEventListener("abort", onAbort); releaseWaiter(); await stopPromise; } } private _requireConnectableServer( serverName: string, signal: AbortSignal | undefined, reset: boolean, ctx: ExtensionContext, ): ManagedServer { const server = this.deps.manager.getServer(serverName); if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config"); if (signal?.aborted) throw new AuthCancelledError(serverName); if (reset && !server.config.auth) { throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config"); } if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName); return server; } // All requests join an in-flight reset. A reset supersedes a normal connect. private _canJoinAttempt(existing: ConnectAttemptEntry, reset: boolean): boolean { return !existing.controller.signal.aborted && (!reset || existing.reset); } private _supersedeAttempt( serverName: string, existing: ConnectAttemptEntry | undefined, ): AbortController { const controller = new AbortController(); if (existing) { existing.controller.abort(); void this.deps.manager.stopServer(serverName); } return controller; } private _startConnectTask( params: ConnectAttemptParams, entry: ConnectAttemptEntry, ): Promise<void> { const { serverName, controller } = params; const task = this._executeConnectTask(params) .catch((err: unknown) => { if (controller.signal.aborted) throw new AuthCancelledError(serverName); throw err; }) .finally(() => { if (this.connectAttempts.get(serverName) === entry) this.connectAttempts.delete(serverName); }); void task.catch(() => { // WHY: Prevent unhandled rejection when task settles after an aborted waiter detaches. }); return task; } private async _executeConnectTask(params: ConnectAttemptParams): Promise<void> { const { serverName, ctx, controller, existing, reset, alreadyQueued } = params; if (existing) { // Wait for a superseded connect to release its callback listener and auth lock. await existing.promise.catch(() => { // WHY: Ignore errors from the superseded connect attempt while waiting for cleanup. }); } this._ensureActive(controller.signal, serverName); if (reset) { this.deps.bridge.deactivateServer(serverName); await this.deps.manager.stopServer(serverName); this._ensureActive(controller.signal, serverName); } if (await this._tryStartServer(params)) { this._markServerConnected(serverName, ctx); return; } if (!ctx.hasUI) throw new AuthRequiredError(serverName); const runInteractiveAuth = (): Promise<void> => this._authorizeAndRestart(params); if (alreadyQueued || params.inheritedStartupTurn) { await runInteractiveAuth(); } else { await this.runSerializedAuth(serverName, controller.signal, runInteractiveAuth); } this._markServerConnected(serverName, ctx); } private async _tryStartServer(params: ConnectAttemptParams): Promise<boolean> { const { serverName, serverConfig, ctx, controller, reset } = params; if (reset) return false; try { await this._startAndGuardAbort(serverName, ctx, controller.signal); return true; } catch (err) { if (err instanceof AuthRequiredError && serverConfig.auth) return false; // WHY: A non-auth connect failure (e.g. ECONNREFUSED) overwrites lastError, so a // hint left over from an earlier decline no longer describes this server's problem. if (!(err instanceof AuthCancelledError)) { this.syncStatus(ctx); } throw err; } } private async _authorizeAndRestart(params: InteractiveAuthParams): Promise<void> { const { serverName, ctx, controller, reset } = params; this._ensureActive(controller.signal, serverName); await this.deps.authorize(serverName, ctx, controller.signal, reset); this._ensureActive(controller.signal, serverName); try { await this._startAndGuardAbort(serverName, ctx, controller.signal); } catch (err) { // WHY: Authorization can succeed and the reconnect still fail for a non-auth reason // (ECONNREFUSED, timeout). lastError now names that failure, so a hint left over from // the earlier decline would tell the user to authorize a server that just authorized. if (!(err instanceof AuthCancelledError)) { this.syncStatus(ctx); } throw err; } } private async _startAndGuardAbort( serverName: string, ctx: ExtensionContext, signal: AbortSignal, ): Promise<void> { await this.deps.manager.startServer(serverName, ctx.cwd); if (!signal.aborted) return; await this.deps.manager.stopServer(serverName); throw new AuthCancelledError(serverName); } private _ensureActive(signal: AbortSignal, serverName: string): void { if (signal.aborted) throw new AuthCancelledError(serverName); } private _markServerConnected(serverName: string, ctx: ExtensionContext): void { this.declinedStartupAuth.delete(serverName); this.syncStatus(ctx); } private _resolveInheritedStartupTurn( serverName: string, alreadyQueued: boolean, ): ActiveStartupTurn | null { if (alreadyQueued || this.activeStartupTurn?.serverName !== serverName) { return null; } return this.activeStartupTurn; } private _handoffAndCancelStartupPrompt( serverName: string, alreadyQueued: boolean, inheritedStartupTurn: ActiveStartupTurn | null, promise: Promise<void>, ): void { if (inheritedStartupTurn) { // WHY: Attach handoffPromise before aborting the active startup prompt so the prompt turn // stays held for this server's interactive auth instead of advancing authQueue to the next server. inheritedStartupTurn.handoffPromise = promise; } if (!alreadyQueued) { this.startupPromptControllers.get(serverName)?.abort(); this.startupPromptControllers.delete(serverName); } } async connect( serverName: string, ctx: ExtensionContext, options?: ConnectOptions, ): Promise<void> { const signal = options?.signal; const reset = options?.reset ?? false; const alreadyQueued = options?.alreadyQueued ?? false; this.activeCtx = ctx; const server = this._requireConnectableServer(serverName, signal, reset, ctx); const inheritedStartupTurn = this._resolveInheritedStartupTurn(serverName, alreadyQueued); const existing = this.connectAttempts.get(serverName); if (existing && this._canJoinAttempt(existing, reset)) { return await this._waitForConnectAttempt(serverName, existing, signal); } const controller = this._supersedeAttempt(serverName, existing); const entry: ConnectAttemptEntry = { promise: Promise.resolve(), controller, reset, waiters: 0, }; entry.promise = this._startConnectTask( { serverName, serverConfig: server.config, ctx, controller, existing, reset, alreadyQueued, inheritedStartupTurn, }, entry, ); this._handoffAndCancelStartupPrompt(serverName, alreadyQueued, inheritedStartupTurn, entry.promise); this.connectAttempts.set(serverName, entry); return await this._waitForConnectAttempt(serverName, entry, signal); } }