import { stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { closeDebug, dbg } from "./src/debug.ts"; import { resolveSetting, type SettingDeclaration, } from "./src/pi-ext-settings.ts"; const SERVER_NAME = "intellij"; const TOOL_NAMESPACE = "mcp__intellij__"; const MAX_ANCESTORS = 128; export const INTELLIJ_URL_SETTING: SettingDeclaration = { key: "intellij.url", parse: (raw) => typeof raw === "string" ? normalizeIntellijUrl(raw) : undefined, default: undefined, }; export function normalizeIntellijUrl(value: string): string { let url: URL; try { url = new URL(value); } catch { throw new Error("IntelliJ MCP URL is malformed."); } if (url.protocol !== "http:") throw new Error("IntelliJ MCP URL must use HTTP."); if (url.username || url.password) throw new Error("IntelliJ MCP URL must not contain credentials."); if (url.hash) throw new Error("IntelliJ MCP URL must not contain a fragment."); if (!isLoopbackHost(url.hostname)) throw new Error("IntelliJ MCP URL must use a loopback host."); return url.href; } function isLoopbackHost(hostname: string): boolean { const host = hostname.toLowerCase(); if (host === "localhost" || host === "[::1]") return true; const parts = host.split("."); return ( parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d+$/.test(part) && Number(part) <= 255) ); } /** Find nearest ancestor containing IntelliJ's project metadata. */ export async function findIdeaProjectRoot( cwd: string, ): Promise { let current = resolve(cwd); for (let depth = 0; depth < MAX_ANCESTORS; depth++) { try { if ((await stat(resolve(current, ".idea"))).isDirectory()) return current; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") throw error; } const parent = dirname(current); if (parent === current) return undefined; current = parent; } return undefined; } export default function intellijExtension(pi: ExtensionAPI): void { let generation = 0; let started = false; let registered = false; pi.on("session_start", (_event, ctx) => { if (started) return; started = true; const current = ++generation; dbg?.("session.start", { generation: current }); void activate(ctx, current).catch(() => { if (current !== generation) return; dbg?.("activation.result", { generation: current, status: "failed" }); ctx.ui.notify( "IntelliJ MCP activation failed; inspect /mcp and intellij.url.", "warning", ); }); }); pi.on("tool_call", (event) => { if ( registered && event.toolName.startsWith(TOOL_NAMESPACE) && projectRoot !== undefined ) Object.assign(event.input, { projectPath: projectRoot }); }); let projectRoot: string | undefined; async function activate( ctx: ExtensionContext, current: number, ): Promise { const root = await findIdeaProjectRoot(ctx.cwd); if (current !== generation || ctx.signal?.aborted) { dbg?.("activation.result", { generation: current, status: "stale" }); return; } if (!root) { dbg?.("activation.result", { generation: current, status: "no_project" }); return; } const setting = resolveSetting(pi, ctx, INTELLIJ_URL_SETTING); if (!setting.ok || !setting.value) { dbg?.("activation.result", { generation: current, status: setting.ok ? "missing_url" : "invalid_url", hasIdea: true, }); ctx.ui.notify( setting.ok ? "IntelliJ project detected. Set intellij.url to IntelliJ's loopback HTTP Stream URL." : `${setting.error}. Set intellij.url to IntelliJ's loopback HTTP Stream URL.`, "warning", ); return; } if (current !== generation || ctx.signal?.aborted) { dbg?.("activation.result", { generation: current, status: "stale" }); return; } projectRoot = root; pi.registerMcpServer(SERVER_NAME, { url: setting.value, exposure: "deferred", description: "IntelliJ project tools. Pi injects the detected project root.", }); registered = true; dbg?.("activation.result", { generation: current, status: "registered", hasIdea: true, hasUrl: true, }); } pi.on("session_shutdown", () => { generation++; started = false; if (registered) pi.unregisterMcpServer(SERVER_NAME); registered = false; projectRoot = undefined; dbg?.("session.shutdown", { generation }); closeDebug(); }); }