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.

175 lines (161 loc) • 7.48 kB
/** * pi-mcp — MCP client extension for the Pi coding agent. * * Entry point registered in package.json under "pi.extensions". * Pi loads this file via jiti (TypeScript executed directly, no build step). * * Wires together: config → server manager → tool bridge → Pi API. */ import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; import { agentDir } from "./config.js"; import type { ConfigProvenance } from "./server-provenance.js"; import { SessionLifecycle, loadBootstrapConfigState, } from "./session-lifecycle.js"; import { registerCommands } from "./register-commands.js"; import { registerManagementTools } from "./register-tools.js"; import { ServerManager, type ManagedServer } from "./server-manager.js"; import { ToolBridge } from "./tool-bridge.js"; import { ResourceBridge } from "./resource-bridge.js"; import { ConnectionCoordinator } from "./connection-coordinator.js"; import { CallbackServer } from "./callback-server.js"; import { authorize as runAuthorize } from "./authorize-flow.js"; import { DebugLog, isDebugLogEnabled, setDebugLog, } from "./debug-log.js"; export { startupAuthNowOption, startupAuthLaterOption, } from "./startup-auth.js"; export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void>; callbackServer?: CallbackServer } = {}): Promise<void> { // ── 0. Opt-in diagnostic log ─────────────────────────────────────────────── // WHY: The debug log is created before anything else so bootstrap failures // are captured too. The file is only touched when the first event is written. if (isDebugLogEnabled()) { setDebugLog(new DebugLog(join(agentDir(), "logs"))); } // ── 1. Load and validate config ────────────────────────────────────────── const initialState = await loadBootstrapConfigState(pi, paths); // ── 2. Initialize bridge components ────────────────────────────────────── const manager = new ServerManager(initialState.config, paths.authStorageDir); const bridge = new ToolBridge(initialState.config.settings, pi); const resources = new ResourceBridge(); const callbackServer = paths.callbackServer ?? new CallbackServer(); const coordinator: ConnectionCoordinator = new ConnectionCoordinator({ manager, bridge, getConfig: () => lifecycle.getConfig(), authorize: (name, ctx, signal, reset) => authorize(name, ctx, signal, reset), stopCallbackServer: () => callbackServer.stop(), }); const lifecycle: SessionLifecycle = new SessionLifecycle({ pi, globalConfigPath: paths.globalConfigPath, initialState, manager, coordinator, bridge, resources, }); manager.setToolRefreshCallback(async (serverName, client, options) => { const server = manager.getServer(serverName); const requestTimeoutMs = options?.requestTimeoutMs ?? server?.config.requestTimeoutMs ?? lifecycle.getConfig().settings.requestTimeoutMs; await bridge.refreshTools( serverName, client, () => isClientActive(manager, serverName, client), { ...options, requestTimeoutMs, }, ); }); manager.setServerStoppedCallback((name) => { bridge.deactivateServer(name); resources.forget(name); }); manager.setRecoveryFailedCallback((serverName, error) => { coordinator.handleRecoveryFailed(serverName, error); }); manager.setServerReadyCallback(() => { // WHY: Eager starts and background _recover() reconnects never pass through // coordinator.connect(), so without this the "run /mcp:auth <name>" footer hint // survives a server that has since connected cleanly. coordinator.syncStatus(); }); manager.setResourceListChangedCallback((name) => { // WHY: resources are listed on demand, so a notification only has to make the cached // catalog stale; the next read re-fetches it from the same client. resources.invalidate(name); }); const handleAuthError = async (serverName: string): Promise<void> => { const authError = await manager.handleUnauthorizedServerError(serverName); if (authError) { // WHY: A mid-session 401/403 stops the server synchronously, which cancels the // deferred _recover() that would have called onRecoveryFailed — so report the // failure here or the re-auth warning and the footer hint never appear. coordinator.handleRecoveryFailed(serverName, authError); } }; bridge.setAuthErrorCallback(handleAuthError); // WHY: a resources/* request can be the first call to hit an expired token, so it needs // the same re-authorization path as tools/call. resources.setAuthErrorCallback(handleAuthError); // ── 3. Session lifecycle ────────────────────────────────────────────────── pi.on("session_start", (_event, ctx: ExtensionContext) => lifecycle.handleSessionStart(ctx)); // Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer(). pi.on("mcp_servers_change", (event, ctx: ExtensionContext) => lifecycle.handleServersChange(event, ctx), ); // Keep active and outgoing tool order independent of connect timing, `/tree` transcript // restoration, idle/mid-turn external `setActiveTools` reorderings (e.g. `tool_search` before // continuation `sections.tools`, or idle shortcuts before `before_agent_start` snapshots // `systemPromptOptions`), and first-appearance transcript replay (#278). const normalizeActiveTools = (): void => { bridge.orderActiveTools(); }; pi.on("session_tree", normalizeActiveTools); pi.on("input", normalizeActiveTools); pi.on("before_agent_start", normalizeActiveTools); pi.on("tool_execution_end", normalizeActiveTools); pi.on("before_provider_request", (event) => bridge.orderPayloadTools(event.payload)); pi.on("session_shutdown", (_event, ctx: ExtensionContext) => lifecycle.handleSessionShutdown(ctx), ); // ── 4. Slash commands ─────────────────────────────────────────────────── const getProvenance = (): ConfigProvenance => lifecycle.getProvenance(); registerCommands(pi, { manager, coordinator, bridge, resources, getProvenance }); const authorize = (serverName: string, ctx: ExtensionContext, signal: AbortSignal, reset: boolean) => runAuthorize( { manager, getServerConfig: (name) => manager.getServer(name)?.config, authStorageDir: paths.authStorageDir, openBrowser: paths.browserOpen, callbackServer, }, serverName, ctx, signal, reset, ); registerManagementTools(pi, { manager, coordinator, bridge, resources, getProvenance }); } function isClientActive( manager: ServerManager, serverName: string, client: ManagedServer["client"], ): boolean { const current = manager.getServer(serverName); return current?.client === client && current.state !== "stopped"; }