import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { lookup } from "node:dns/promises"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, keyHint, type Theme, truncateHead, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { type TSchema, Type } from "typebox"; import { dbg, span } from "./src/debug.ts"; import { findExecutable } from "./src/pi-ext-executable.ts"; import { resolveSetting, type SettingDeclaration, } from "./src/pi-ext-settings.ts"; export type IntellijState = | "disconnected" | "connecting" | "connected" | "failed"; export type IntellijCommand = | "connect" | "disconnect" | "reconnect" | "status" | "tools"; type JsonObject = Record; type RemoteTool = { name: string; description?: string; inputSchema: JsonObject & { type: "object" }; }; type CatalogTool = RemoteTool & { localName: string; acceptsProjectPath: boolean; agentSchema: JsonObject & { type: "object" }; }; type BridgeContent = | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; type BridgeResult = { content: BridgeContent[]; details: { source: "intellij"; tool: string }; }; type McpResult = { content?: unknown[]; structuredContent?: JsonObject; isError?: boolean; toolResult?: unknown; }; interface McpConnection { serverName: string; listTools(signal?: AbortSignal): Promise; callTool( name: string, args: JsonObject, signal: AbortSignal | undefined, onProgress?: (progress: unknown) => void, ): Promise; close(): Promise; onClose(handler: () => void): void; } export interface IntellijRuntime { run( command: IntellijCommand, argument: string, ctx: ExtensionCommandContext, ): Promise; shutdown(): Promise; } const CONNECT_TIMEOUT_MS = 3_000; const DEFAULT_TOOL_TIMEOUT_MS = 60_000; const TOOL_TIMEOUT_GRACE_MS = 5_000; const RETRY_WINDOW_MS = 8_000; const RETRY_DELAY_MS = 400; const MAX_CATALOG_TOOLS = 512; const MAX_SCHEMA_BYTES = 256 * 1024; const VALID_SCHEMA_TYPES = new Set([ "null", "boolean", "object", "array", "number", "integer", "string", ]); const SCHEMA_MAP_KEYWORDS = [ "properties", "patternProperties", "$defs", "definitions", ] as const; const SCHEMA_CHILD_KEYWORDS = [ "additionalProperties", "unevaluatedProperties", "items", "contains", "propertyNames", "not", "if", "then", "else", ] as const; const SCHEMA_ARRAY_KEYWORDS = [ "allOf", "anyOf", "oneOf", "prefixItems", ] as const; const SCHEMA_COMPOSITION_KEYWORDS = ["allOf", "anyOf", "oneOf"] as const; const PROJECT_PROBE = "get_project_modules"; // nf-dev-intellij, U+E7B5. const INTELLIJ_ICON = ""; const TOOL_GUIDANCE = [ "Use intellij_* tools when IDE indexes, inspections, refactoring, builds, run configurations, debugging, or database integrations provide value; prefer built-in tools for plain filesystem and shell work.", "After IntelliJ changes source, use available intellij_* inspection tools and an available IntelliJ build tool when build state matters.", ]; const RECOVERY = "In IntelliJ, open Settings | Tools | MCP Server | Manual Client Configuration, click Copy HTTP Stream Config, then run /intellij connect ."; class ProjectUnavailableError extends Error {} class EndpointValidationError extends Error {} 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 match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host); return match !== null && Number(match[1]) === 127; } export function acceptsProjectPath(schema: JsonObject): boolean { return acceptsProjectPathIn(schema, schema, new Set()); } function acceptsProjectPathIn( schema: unknown, root: JsonObject, visitedRefs: Set, ): boolean { if (!isRecord(schema)) return false; const properties = schema.properties; if ( isRecord(properties) && schemaAcceptsString(properties.projectPath, root, visitedRefs) ) return true; if (typeof schema.$ref === "string") { if (visitedRefs.has(schema.$ref)) return false; visitedRefs.add(schema.$ref); const target = resolveLocalRef(root, schema.$ref); if (acceptsProjectPathIn(target, root, visitedRefs)) return true; } return SCHEMA_COMPOSITION_KEYWORDS.some((keyword) => { const value = schema[keyword]; return ( Array.isArray(value) && value.some((entry) => acceptsProjectPathIn(entry, root, visitedRefs)) ); }); } function schemaAcceptsString( schema: unknown, root: JsonObject, visitedRefs: Set, ): boolean { if (!isRecord(schema)) return false; if (schema.type === "string") return true; if (Array.isArray(schema.type) && schema.type.includes("string")) return true; if (typeof schema.$ref === "string") { if (visitedRefs.has(schema.$ref)) return false; visitedRefs.add(schema.$ref); if ( schemaAcceptsString(resolveLocalRef(root, schema.$ref), root, visitedRefs) ) return true; } return SCHEMA_COMPOSITION_KEYWORDS.some((keyword) => { const value = schema[keyword]; return ( Array.isArray(value) && value.some((entry) => schemaAcceptsString(entry, root, visitedRefs)) ); }); } function resolveLocalRef(root: JsonObject, reference: string): unknown { if (!reference.startsWith("#/")) return undefined; let value: unknown = root; for (const encoded of reference.slice(2).split("/")) { if (!isRecord(value)) return undefined; const key = encoded.replaceAll("~1", "/").replaceAll("~0", "~"); value = value[key]; } return value; } export function schemaWithoutInjectedProjectPath( schema: JsonObject & { type: "object" }, ): JsonObject & { type: "object" } { const nodesToStrip = new Set(); const visited = new Set(); const findProjectSchemas = (node: unknown): void => { if (!isRecord(node) || visited.has(node)) return; visited.add(node); if ( (isRecord(node.properties) && "projectPath" in node.properties) || (Array.isArray(node.required) && node.required.includes("projectPath")) ) nodesToStrip.add(node); if (typeof node.$ref === "string") findProjectSchemas(resolveLocalRef(schema, node.$ref)); for (const keyword of SCHEMA_COMPOSITION_KEYWORDS) { const children = node[keyword]; if (Array.isArray(children)) for (const child of children) findProjectSchemas(child); } }; findProjectSchemas(schema); if (nodesToStrip.size === 0) return schema; // MCP schemas are validated immutable JSON. Copy only paths leading to the // bridge-owned field; cloning every untouched schema node dominated connect CPU. const copyChangedPaths = (value: unknown): unknown => { if (Array.isArray(value)) { let changed = false; const children = value.map((child) => { const copy = copyChangedPaths(child); if (copy !== child) changed = true; return copy; }); return changed ? children : value; } if (!isRecord(value)) return value; let copy: JsonObject | undefined; for (const [key, child] of Object.entries(value)) { const childCopy = copyChangedPaths(child); if (childCopy !== child) { copy ??= { ...value }; copy[key] = childCopy; } } if (nodesToStrip.has(value)) { const current = copy ?? value; if (isRecord(current.properties) && "projectPath" in current.properties) { copy ??= { ...value }; copy.properties = { ...current.properties }; delete (copy.properties as JsonObject).projectPath; } if ( Array.isArray(current.required) && current.required.includes("projectPath") ) { copy ??= { ...value }; copy.required = current.required.filter( (field) => field !== "projectPath", ); } } return copy ?? value; }; return copyChangedPaths(schema) as JsonObject & { type: "object" }; } export function validateRemoteTool(value: unknown): RemoteTool { if (!isRecord(value)) throw new Error("MCP advertised a non-object tool."); if ( typeof value.name !== "string" || value.name.length === 0 || value.name.length > 128 || !/^[-A-Za-z0-9_.]+$/.test(value.name) ) throw new Error("MCP advertised an invalid tool name."); validateSchema(value.inputSchema); if (value.description !== undefined && typeof value.description !== "string") throw new Error(`MCP tool ${value.name} has an invalid description.`); return { name: value.name, description: value.description as string | undefined, inputSchema: value.inputSchema as RemoteTool["inputSchema"], }; } function validateSchema(value: unknown): void { if (!isRecord(value) || value.type !== "object") throw new Error("MCP tool input schema must be an object schema."); const encoded = JSON.stringify(value); if (encoded.length > MAX_SCHEMA_BYTES) throw new Error("MCP tool input schema is too large."); validateSchemaNode(value, value, 0, { count: 0 }); } function validateSchemaNode( value: unknown, root: JsonObject, depth: number, budget: { count: number }, ): void { if (!isRecord(value) || depth > 64 || ++budget.count > 10_000) throw new Error("MCP tool schema contains an invalid nested schema."); if ( value.type !== undefined && !( (typeof value.type === "string" && VALID_SCHEMA_TYPES.has(value.type)) || (Array.isArray(value.type) && value.type.length > 0 && value.type.every( (type) => typeof type === "string" && VALID_SCHEMA_TYPES.has(type), )) ) ) throw new Error("MCP tool schema has an invalid type."); if (value.$ref !== undefined) { if ( typeof value.$ref !== "string" || !value.$ref.startsWith("#/") || resolveLocalRef(root, value.$ref) === undefined ) throw new Error("MCP tool schema has an unsupported reference."); } if (value.required !== undefined) { if ( !Array.isArray(value.required) || !value.required.every((field) => typeof field === "string") || new Set(value.required).size !== value.required.length ) throw new Error("MCP tool schema required fields are invalid."); const properties = value.properties as JsonObject | undefined; if ( isRecord(properties) && value.required.some((field) => !(field in properties)) ) throw new Error("MCP tool schema requires an unknown field."); } for (const keyword of SCHEMA_MAP_KEYWORDS) { const entries = value[keyword]; if (entries === undefined) continue; if (!isRecord(entries)) throw new Error(`MCP tool schema ${keyword} must be an object.`); for (const child of Object.values(entries)) validateSchemaNode(child, root, depth + 1, budget); } for (const keyword of SCHEMA_CHILD_KEYWORDS) { const child = value[keyword]; if (child === undefined || typeof child === "boolean") continue; validateSchemaNode(child, root, depth + 1, budget); } for (const keyword of SCHEMA_ARRAY_KEYWORDS) { const children = value[keyword]; if (children === undefined) continue; if (!Array.isArray(children) || children.length === 0) throw new Error(`MCP tool schema ${keyword} must be a non-empty array.`); for (const child of children) validateSchemaNode(child, root, depth + 1, budget); } } function isRecord(value: unknown): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } export type IdeaLauncher = { command: string; argsBeforePath: string[]; script?: string; }; export async function findIdeaLauncher( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): Promise { const pathValue = getEnv(env, "PATH", platform === "win32"); if (!pathValue) return undefined; const host = platform === "win32" ? getEnv(env, "COMSPEC", true) : undefined; const names = platform === "win32" ? host ? ["idea64.exe", "idea.exe", "idea.cmd", "idea.bat"] : ["idea64.exe", "idea.exe"] : ["idea"]; const executable = findExecutable(names, { path: pathValue, platform }); if (!executable) return undefined; if (/\.(?:cmd|bat)$/iu.test(executable)) { if (!host) return undefined; return { command: host, argsBeforePath: ["/d", "/v:off", "/s", "/c"], script: executable, }; } return { command: executable, argsBeforePath: [] }; } function getEnv( env: NodeJS.ProcessEnv, key: string, caseInsensitive: boolean, ): string | undefined { if (!caseInsensitive) return env[key]; const actual = Object.keys(env).find( (candidate) => candidate.toLowerCase() === key.toLowerCase(), ); return actual ? env[actual] : undefined; } async function launchIdea(projectPath: string): Promise { const launcher = await findIdeaLauncher(); if (!launcher) throw new Error( "IntelliJ launcher 'idea' is not on PATH. In IntelliJ, use Tools | Create Command-line Launcher.", ); const args = launcher.script ? [ ...launcher.argsBeforePath, `"${quoteCmdPath(launcher.script)} ${quoteCmdPath(projectPath)}"`, ] : [...launcher.argsBeforePath, projectPath]; await new Promise((resolveLaunch, reject) => { const child = spawn(launcher.command, args, { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: launcher.script !== undefined, }); child.once("error", reject); child.once("spawn", () => { child.unref(); resolveLaunch(); }); }); } function quoteCmdPath(value: string): string { if (/[%!"\r\n\0]/.test(value)) throw new Error("IntelliJ launcher path contains unsupported characters."); const escaped = value .replaceAll("^", "^^") .replace(/[&|<>()]/g, (character) => `^${character}`); return `"${escaped}"`; } function settingsPath(): string { return join(getAgentDir(), "settings.json"); } // Precedence: trusted project, user, none. const URL_SETTING: SettingDeclaration = { key: "intellij.url", parse: (raw) => (typeof raw === "string" ? raw : undefined), default: undefined, }; export async function persistUrl(url: string): Promise { const file = settingsPath(); let settings: JsonObject = {}; try { const parsed = JSON.parse(await readFile(file, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error("Global settings must be an object."); settings = parsed; } catch (error) { if (!(isNodeError(error) && error.code === "ENOENT")) throw error; } const intellij = isRecord(settings.intellij) ? settings.intellij : {}; settings.intellij = { ...intellij, url }; await mkdir(dirname(file), { recursive: true }); const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; try { await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx", }); await rename(temporary, file); } finally { await rm(temporary, { force: true }).catch(() => {}); } } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } async function openMcpConnection( url: string, signal?: AbortSignal, ): Promise { const endpoint = new URL(url); await verifyLoopbackResolution(endpoint.hostname); const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([ import("@modelcontextprotocol/sdk/client/index.js"), import("@modelcontextprotocol/sdk/client/streamableHttp.js"), ]); const client = new Client( { name: "pi-intellij", version: "0.1.0" }, { capabilities: {} }, ); const transport = new StreamableHTTPClientTransport(endpoint, { fetch: async (input, init) => { const target = new URL(input); if (target.protocol !== "http:" || !isLoopbackHost(target.hostname)) throw new Error("IntelliJ MCP request escaped the loopback host."); const response = await fetch(input, { ...init, redirect: "manual" }); if (response.status >= 300 && response.status < 400) { await response.body?.cancel(); throw new Error("IntelliJ MCP endpoint redirected the request."); } return response; }, }); try { await client.connect(transport, { signal, timeout: CONNECT_TIMEOUT_MS }); } catch (error) { await client.close().catch(() => {}); throw error; } return { serverName: client.getServerVersion()?.name ?? "", async listTools(requestSignal) { const tools: RemoteTool[] = []; let cursor: string | undefined; do { const page = await client.listTools(cursor ? { cursor } : undefined, { signal: requestSignal, timeout: CONNECT_TIMEOUT_MS, }); for (const tool of page.tools) { tools.push(validateRemoteTool(tool)); if (tools.length > MAX_CATALOG_TOOLS) throw new Error("IntelliJ exposed too many MCP tools."); } cursor = page.nextCursor; } while (cursor); return tools; }, async callTool(name, args, requestSignal, onProgress) { return (await client.callTool({ name, arguments: args }, undefined, { signal: requestSignal, timeout: toolCallTimeout(args), resetTimeoutOnProgress: true, onprogress: onProgress, })) as McpResult; }, close: () => client.close(), onClose(handler) { client.onclose = handler; }, }; } async function verifyLoopbackResolution(hostname: string): Promise { if (hostname !== "localhost") return; const addresses = await lookup(hostname, { all: true }); if ( addresses.length === 0 || addresses.some( ({ address }) => address !== "::1" && !address.startsWith("127.") && !address.toLowerCase().startsWith("::ffff:127."), ) ) throw new Error( "localhost does not resolve exclusively to loopback addresses.", ); } function verifyJetBrains(connection: McpConnection): void { if ( !/(jetbrains|intellij|idea|pycharm|webstorm|rider|clion)/i.test( connection.serverName, ) ) throw new EndpointValidationError( `Endpoint is not a JetBrains MCP server: ${connection.serverName || "unnamed server"}.`, ); } async function verifyProject( connection: McpConnection, tools: RemoteTool[], projectPath: string, signal?: AbortSignal, ): Promise { const probe = tools.find( (tool) => tool.name === PROJECT_PROBE && acceptsProjectPath(tool.inputSchema), ); if (!probe) throw new EndpointValidationError( "IntelliJ must expose get_project_modules so Pi can verify the session project.", ); const result = await connection.callTool(probe.name, { projectPath }, signal); if (result.isError) throw new ProjectUnavailableError( resultText(result) || "Project is not open.", ); } type ConnectionResult = { connection: McpConnection; tools: RemoteTool[]; }; async function connectOnce( url: string, projectPath: string, signal?: AbortSignal, ): Promise { const timeout = AbortSignal.timeout(CONNECT_TIMEOUT_MS); const boundedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; let connection: McpConnection | undefined; try { connection = await openMcpConnection(url, boundedSignal); verifyJetBrains(connection); let tools: RemoteTool[]; try { tools = await connection.listTools(boundedSignal); } catch (error) { throw new EndpointValidationError( error instanceof Error ? error.message : String(error), ); } await verifyProject(connection, tools, projectPath, boundedSignal); return { connection, tools }; } catch (error) { await connection?.close().catch(() => {}); throw error; } } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolveSleep, reject) => { if (signal?.aborted) return reject(signal.reason); const timer = setTimeout(resolveSleep, ms); signal?.addEventListener( "abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }, ); }); } async function connectWithLaunch( url: string, projectPath: string, signal: AbortSignal, allowLaunch: boolean, ): Promise { try { return await connectOnce(url, projectPath, signal); } catch (error) { if (signal.aborted) throw signal.reason; if (!allowLaunch || error instanceof EndpointValidationError) throw error; } await launchIdea(projectPath); signal.throwIfAborted(); const deadline = Date.now() + RETRY_WINDOW_MS; for (;;) { try { return await connectOnce(url, projectPath, signal); } catch (error) { if (signal.aborted) throw signal.reason; if (Date.now() >= deadline) throw error; await sleep(RETRY_DELAY_MS, signal); } } } function samePath(left: string, right: string): boolean { const normalize = (value: string) => { const absolute = resolve(value).replace(/[\\/]+$/, ""); return process.platform === "win32" ? absolute.toLowerCase() : absolute; }; return normalize(left) === normalize(right); } function localBase(remoteName: string): string { return `intellij_${remoteName.replace(/[^A-Za-z0-9_-]/g, "_")}`; } function resultText(result: McpResult): string { const pieces: string[] = []; for (const item of result.content ?? []) { if (isRecord(item) && item.type === "text" && typeof item.text === "string") pieces.push(item.text); } if (pieces.length === 0 && result.structuredContent !== undefined) pieces.push(safeJson(result.structuredContent)); if (pieces.length === 0 && result.toolResult !== undefined) pieces.push(safeJson(result.toolResult)); return boundedText(pieces.join("\n")); } function safeJson(value: unknown): string { try { return JSON.stringify(value); } catch { return "[unserializable MCP content]"; } } export function toolCallTimeout(args: JsonObject): number { const requested = args.timeout; const operationTimeout = typeof requested === "number" && Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_TOOL_TIMEOUT_MS; return Math.min(operationTimeout + TOOL_TIMEOUT_GRACE_MS, 2_147_000_000); } function boundedText(value: string): string { const result = truncateHead(value, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES, }); return result.truncated ? `${result.content}\n[IntelliJ output truncated: ${result.outputBytes}/${result.totalBytes} bytes, ${result.outputLines}/${result.totalLines} lines]` : result.content; } function mapResult(result: McpResult, toolName: string): BridgeResult { const content: BridgeContent[] = []; const seenText = new Set(); const appendText = (text: string) => { const parsed = parseJson(text); const identity = parsed === undefined ? text : safeJson(parsed); if (seenText.has(identity)) return; seenText.add(identity); content.push({ type: "text", text: boundedText(text) }); }; for (const item of result.content ?? []) { if ( isRecord(item) && item.type === "text" && typeof item.text === "string" ) { appendText(item.text); } else if ( isRecord(item) && item.type === "image" && typeof item.data === "string" && typeof item.mimeType === "string" ) { content.push({ type: "image", data: item.data, mimeType: item.mimeType }); } else { appendText(safeJson(item)); } } if (result.structuredContent !== undefined) appendText(safeJson(result.structuredContent)); if (result.toolResult !== undefined) appendText(safeJson(result.toolResult)); if (content.length === 0) content.push({ type: "text", text: "(empty result)" }); return { content, details: { source: "intellij", tool: toolName } }; } function parseJson(text: string): unknown { try { return JSON.parse(text); } catch { return undefined; } } function mcpPayload(result: McpResult): unknown { if (result.structuredContent !== undefined) return result.structuredContent; const texts = (result.content ?? []).filter( (item): item is JsonObject & { type: "text"; text: string } => isRecord(item) && item.type === "text" && typeof item.text === "string", ); return texts.length === 1 ? parseJson(texts[0].text) : undefined; } function bridgePayload(result: BridgeResult): unknown { const texts = result.content.filter( (item): item is { type: "text"; text: string } => item.type === "text", ); return texts.length === 1 ? parseJson(texts[0].text) : undefined; } function semanticFailure(result: McpResult): boolean { const payload = mcpPayload(result); return ( isRecord(payload) && (payload.isSuccess === false || payload.success === false) ); } function normalizeDescription( tool: RemoteTool, projectPathInjected: boolean, ): string { const routing = projectPathInjected ? "Pi pins this call to the connected session project and injects projectPath automatically." : "Pi pins this call to the connected IntelliJ session."; const fallback = `Call IntelliJ MCP tool ${tool.name}. ${routing}`; if (!tool.description?.trim()) return fallback; const lines = tool.description.trim().split(/\r?\n/); const indents = lines .slice(1) .filter((line) => line.trim()) .map((line) => /^\s*/.exec(line)?.[0].length ?? 0); const indent = indents.length > 0 ? Math.min(...indents) : 0; const description = lines .map((line, index) => index === 0 ? line.trimEnd() : line.slice(Math.min(indent, line.length)).trimEnd(), ) .join("\n") .replace(/\n{3,}/g, "\n\n"); return `${description}\n\n${routing}`; } function setConnectionStatus( ctx: ExtensionContext, state: "connecting" | "connected" | "failed", ): void { const presentation = { connecting: ["warning", `${INTELLIJ_ICON} …`], connected: ["success", `${INTELLIJ_ICON} ✓`], failed: ["error", `${INTELLIJ_ICON} ✗`], } as const; const [color, text] = presentation[state]; ctx.ui.setStatus("intellij", ctx.ui.theme.fg(color, text)); } function visibleArgs(args: JsonObject): JsonObject { const visible = { ...args }; delete visible.projectPath; return visible; } function short(value: string, limit = 120): string { const oneLine = value.replace(/\s+/g, " ").trim(); return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine; } function callSummary(toolName: string, args: JsonObject): string { const visible = visibleArgs(args); if (toolName === "apply_patch" && typeof visible.input === "string") { const files = visible.input.match(/^\*\*\* (?:Update|Add|Delete) File:/gm)?.length ?? 0; const hunks = visible.input.match(/^@@/gm)?.length ?? 0; return `${files} file${files === 1 ? "" : "s"}, ${hunks} hunk${hunks === 1 ? "" : "s"}`; } const path = [ "filePath", "pathInProject", "path", "file_path", "directoryPath", ] .map((key) => visible[key]) .find((value) => typeof value === "string"); if (typeof path === "string") { if ( toolName === "rename_refactoring" && typeof visible.symbolName === "string" && typeof visible.newName === "string" ) return `${path} · ${visible.symbolName} → ${visible.newName}`; return path; } if (Array.isArray(visible.filesToRebuild)) return `${visible.filesToRebuild.length} file${visible.filesToRebuild.length === 1 ? "" : "s"}`; for (const key of [ "searchText", "regexPattern", "globPattern", "nameKeyword", "query", "queryText", "command", "configurationName", "symbolName", "action", ]) { if (typeof visible[key] === "string") return short(String(visible[key])); } const first = Object.entries(visible).find( ([key, value]) => key !== "timeout" && value !== undefined, ); return first ? `${first[0]}=${short(safeJson(first[1]), 80)}` : "project"; } function formatToolCall( tool: CatalogTool, args: JsonObject, theme: Theme, expanded: boolean, ): string { const title = theme.fg( "toolTitle", theme.bold(`${INTELLIJ_ICON} ${tool.name.replaceAll("_", " ")}`), ); const summary = callSummary(tool.name, args); let text = `${title} ${theme.fg("muted", summary)}`; if (!expanded) return text; const visible = visibleArgs(args); if (tool.name === "apply_patch" && typeof visible.input === "string") { const patch = visible.input; delete visible.input; if (Object.keys(visible).length > 0) text += `\n${theme.fg("dim", JSON.stringify(visible, null, 2))}`; return `${text}\n${theme.fg("toolOutput", patch)}`; } if (Object.keys(visible).length > 0) text += `\n${theme.fg("dim", JSON.stringify(visible, null, 2))}`; return text; } function firstText(result: BridgeResult): string { return ( result.content.find( (item): item is { type: "text"; text: string } => item.type === "text", )?.text ?? "" ); } function issuePresentation(issues: unknown[]): { status: "error" | "warning"; prefix: "✗" | "⚠"; text: string; } { let errors = 0; let warnings = 0; for (const issue of issues) { if (!isRecord(issue)) continue; const severity = String(issue.kind ?? issue.severity ?? "").toUpperCase(); if (severity.includes("ERROR")) errors++; else if (severity.includes("WARN")) warnings++; } const parts = [ errors > 0 ? `${errors} error${errors === 1 ? "" : "s"}` : "", warnings > 0 ? `${warnings} warning${warnings === 1 ? "" : "s"}` : "", ].filter(Boolean); return { status: errors > 0 ? "error" : "warning", prefix: errors > 0 ? "✗" : "⚠", text: parts.length > 0 ? parts.join(", ") : `${issues.length} problem${issues.length === 1 ? "" : "s"}`, }; } function resultSummary( toolName: string, result: BridgeResult, isError: boolean, ): { status: "success" | "error" | "warning" | "muted"; text: string } { const text = firstText(result); if (isError) return { status: "error", text: `✗ ${short(text.split(/\r?\n/, 1)[0] || "failed")}`, }; const payload = bridgePayload(result); if (isRecord(payload)) { if (payload.isSuccess === false || payload.success === false) { const problems = Array.isArray(payload.problems) ? payload.problems : []; return { status: "error", text: `✗ failed${problems.length > 0 ? ` · ${issuePresentation(problems).text}` : ""}`, }; } for (const key of ["errors", "problems"]) { if (Array.isArray(payload[key])) { const issues = payload[key]; if (issues.length === 0) return { status: "success", text: "✓ no problems" }; const presentation = issuePresentation(issues); return { status: presentation.status, text: `${presentation.prefix} ${presentation.text}`, }; } } if (Array.isArray(payload.entries)) return { status: "success", text: `✓ ${payload.entries.length} match${payload.entries.length === 1 ? "" : "es"}${payload.probablyHasMoreMatchingEntries ? "+" : ""}`, }; for (const [key, value] of Object.entries(payload)) { if (Array.isArray(value)) return { status: "success", text: `✓ ${value.length} ${key.replaceAll("_", " ")}`, }; } if (payload.isSuccess === true || payload.success === true) return { status: "success", text: "✓ succeeded" }; } if ( /(?:read_file|get_file_text_by_path)$/.test(toolName) && text.includes("\n") ) { const lines = text.split(/\r?\n/).length; return { status: "success", text: `✓ ${lines} lines` }; } if (/^(?:ok|\[success\])$/i.test(text.trim())) return { status: "success", text: "✓ done" }; if (/^(?:Applied|Created|Updated|Reformatted|Renamed)/i.test(text.trim())) return { status: "success", text: `✓ ${short(text, 100)}` }; return { status: "success", text: "✓ completed" }; } function formatIssues(payload: JsonObject): string | undefined { const issues = Array.isArray(payload.problems) ? payload.problems : Array.isArray(payload.errors) ? payload.errors : undefined; if (!issues || issues.length === 0) return undefined; return issues .map((issue) => { if (!isRecord(issue)) return safeJson(issue); const severity = String(issue.kind ?? issue.severity ?? "PROBLEM"); const file = String(issue.file ?? payload.filePath ?? ""); const line = typeof issue.line === "number" ? `:${issue.line}` : ""; const column = typeof issue.column === "number" ? `:${issue.column}` : ""; const location = file ? ` ${file}${line}${column}` : ""; const message = String( issue.description ?? issue.message ?? safeJson(issue), ); return `${severity}${location}\n ${message}`; }) .join("\n"); } function formatPayload(payload: unknown): string { if (!isRecord(payload)) return JSON.stringify(payload, null, 2); const issues = formatIssues(payload); if (issues) return issues; if (Array.isArray(payload.entries)) return payload.entries .map((entry) => { if (!isRecord(entry)) return safeJson(entry); const path = String(entry.filePath ?? entry.path ?? ""); const line = typeof entry.lineNumber === "number" ? `:${entry.lineNumber}` : ""; const body = String(entry.lineText ?? entry.text ?? ""); return `${path}${line}${body ? `\n ${body}` : ""}`; }) .join("\n"); for (const [key, value] of Object.entries(payload)) { if ( Array.isArray(value) && value.every((entry) => isRecord(entry) && typeof entry.name === "string") ) return `${key}:\n${value .map( (entry) => `${String((entry as JsonObject).name)}${typeof (entry as JsonObject).type === "string" ? ` · ${String((entry as JsonObject).type)}` : ""}`, ) .join("\n")}`; } return JSON.stringify(payload, null, 2); } function formatExpandedResult(result: BridgeResult, theme: Theme): string { return result.content .map((item) => { if (item.type === "image") return theme.fg("dim", `[image ${item.mimeType}]`); const payload = parseJson(item.text); return theme.fg( "toolOutput", payload === undefined ? item.text : formatPayload(payload), ); }) .join("\n"); } function failureHint(message: string): string { if (/timed? out|timeout/i.test(message)) return "Increase the tool timeout or narrow the operation, then retry."; if (/doesn.t exist|can.t be opened|file not found/i.test(message)) return "Verify the project-relative path and whether the file still exists, then retry."; if (/disconnected|connection|fetch failed|socket/i.test(message)) return "Run /intellij status and reconnect before retrying."; return "Correct the listed issue or arguments, then retry the IntelliJ operation."; } function toolFailure( tool: CatalogTool, args: JsonObject, projectPath: string, message: string, ): Error { return new Error( `IntelliJ ${tool.name} failed for ${callSummary(tool.name, args)} in ${projectPath}:\n${boundedText(message)}\nNext: ${failureHint(message)}`, ); } class Runtime implements IntellijRuntime { private state: IntellijState = "disconnected"; private endpoint: string | undefined; private projectPath: string | undefined; private connection: McpConnection | undefined; private catalog: CatalogTool[] = []; private liveCatalog: CatalogTool[] = []; private readonly ownedNames = new Set(); private readonly localByRemote = new Map(); private generation = 0; private pendingAbort: AbortController | undefined; private readonly pi: ExtensionAPI; constructor(pi: ExtensionAPI) { this.pi = pi; } async run( command: IntellijCommand, argument: string, ctx: ExtensionCommandContext, ): Promise { switch (command) { case "connect": await this.connect(argument, ctx, true); return; case "disconnect": await this.disconnect(); ctx.ui.setStatus("intellij", undefined); ctx.ui.notify("IntelliJ MCP disconnected.", "info"); return; case "reconnect": await this.reconnect(ctx); return; case "status": this.showStatus(ctx); return; case "tools": this.showTools(ctx); } } async shutdown(): Promise { await this.disconnect(); } private async connect( argument: string, ctx: ExtensionCommandContext, allowLaunch: boolean, ): Promise { await this.disconnect(); ctx.ui.setStatus("intellij", undefined); let url!: string; let rawUrl = argument; const fromSavedUrl = !argument; let fromProject = false; try { if (!rawUrl) { const saved = resolveSetting(this.pi, ctx, URL_SETTING); if (!saved.ok) { dbg?.("connection.result", { status: "failed" }); ctx.ui.notify(`${saved.error}. ${RECOVERY}`, "warning"); return; } if (!saved.value) { dbg?.("connection.result", { status: "no_url" }); ctx.ui.notify(`No saved IntelliJ MCP URL. ${RECOVERY}`, "warning"); return; } rawUrl = saved.value; fromProject = saved.source === "project"; } url = normalizeIntellijUrl(rawUrl); } catch (error) { dbg?.("connection.result", { status: "failed" }); this.fail(ctx, error, rawUrl || undefined, fromSavedUrl); return; } const generation = ++this.generation; const abort = new AbortController(); this.pendingAbort = abort; const signal = ctx.signal ? AbortSignal.any([ctx.signal, abort.signal]) : abort.signal; this.state = "connecting"; this.endpoint = url; this.projectPath = ctx.cwd; setConnectionStatus(ctx, "connecting"); let attemptedConnection: McpConnection | undefined; try { const connected = await connectWithLaunch( url, ctx.cwd, signal, allowLaunch, ); attemptedConnection = connected.connection; if (generation !== this.generation) { dbg?.("connection.result", { status: "stale" }); await attemptedConnection.close().catch(() => {}); return; } await this.activate( connected.connection, connected.tools, ctx, generation, ); attemptedConnection = undefined; // Project URLs stay project-scoped; only user URLs are machine-wide. if (!fromProject) await persistUrl(url); if ( generation !== this.generation || this.connection !== connected.connection ) return; this.state = "connected"; setConnectionStatus(ctx, "connected"); ctx.ui.notify( `IntelliJ MCP connected: ${this.catalog.length} tools for ${ctx.cwd}`, "info", ); dbg?.("connection.result", { status: "connected" }); } catch (error) { await attemptedConnection?.close().catch(() => {}); if (generation !== this.generation) { dbg?.("connection.result", { status: "cancelled" }); return; } dbg?.("connection.result", { status: signal.aborted ? "cancelled" : "failed", }); await this.closeConnection(); this.deactivateTools(); this.fail(ctx, error, url, fromSavedUrl); } finally { if (generation === this.generation) this.pendingAbort = undefined; } } private async reconnect(ctx: ExtensionCommandContext): Promise { await this.connect("", ctx, false); } private async activate( connection: McpConnection, tools: RemoteTool[], ctx: ExtensionCommandContext, generation: number, ): Promise { const names = new Set(this.pi.getAllTools().map((tool) => tool.name)); const seenRemote = new Set(); const catalog: CatalogTool[] = []; for (const tool of [...tools].sort((a, b) => a.name.localeCompare(b.name), )) { if (seenRemote.has(tool.name)) throw new Error(`IntelliJ advertised duplicate tool ${tool.name}.`); seenRemote.add(tool.name); let localName = this.localByRemote.get(tool.name); if (!localName) { const base = localBase(tool.name); localName = base; for (let suffix = 2; names.has(localName); suffix++) localName = `${base}_${suffix}`; this.localByRemote.set(tool.name, localName); } names.add(localName); this.ownedNames.add(localName); const catalogTool: CatalogTool = { ...tool, localName, acceptsProjectPath: acceptsProjectPath(tool.inputSchema), agentSchema: schemaWithoutInjectedProjectPath(tool.inputSchema), }; catalog.push(catalogTool); this.registerAdapter(catalogTool); } this.connection = connection; this.catalog = catalog; this.liveCatalog = catalog; this.projectPath = ctx.cwd; connection.onClose(() => { if (generation !== this.generation || this.connection !== connection) return; this.connection = undefined; this.liveCatalog = []; this.state = "failed"; this.deactivateTools(); setConnectionStatus(ctx, "failed"); }); const active = this.pi .getActiveTools() .filter((name) => !this.ownedNames.has(name)); this.pi.setActiveTools([ ...active, ...catalog.map((tool) => tool.localName), ]); } private registerAdapter(tool: CatalogTool): void { const runtime = this; this.pi.registerTool({ name: tool.localName, label: `${INTELLIJ_ICON} ${tool.name.replaceAll("_", " ")}`, description: normalizeDescription(tool, tool.acceptsProjectPath), promptGuidelines: tool.name === PROJECT_PROBE ? TOOL_GUIDANCE : undefined, parameters: Type.Unsafe(tool.agentSchema) as TSchema, renderCall(args, theme, context) { const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); component.setText( formatToolCall(tool, args as JsonObject, theme, context.expanded), ); return component; }, renderResult(result, { expanded, isPartial }, theme, context) { const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const bridgeResult = result as BridgeResult; if (isPartial) { const progress = firstText(bridgeResult).replace( /^IntelliJ progress:\s*/, "", ); let text = theme.fg( "warning", expanded ? `… ${progress || "working"}` : "… working", ); if (!expanded && progress) text += ` ${theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`)}`; component.setText(text); return component; } const summary = resultSummary(tool.name, bridgeResult, context.isError); let text = theme.fg(summary.status, summary.text); if (expanded) { const detail = formatExpandedResult(bridgeResult, theme); if (detail) text += `\n${detail}`; } else { text += ` ${theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`)}`; } component.setText(text); return component; }, async execute(_id, params, signal, onUpdate, ctx) { return await runtime.callRemote( tool, params as JsonObject, signal, onUpdate, ctx, ); }, }); } private async callRemote( tool: CatalogTool, params: JsonObject, signal: AbortSignal | undefined, onUpdate: | ((result: { content: Array<{ type: "text"; text: string }>; details: { source: "intellij"; tool: string }; }) => void) | undefined, ctx: ExtensionContext, ) { const end = span?.("tool.call"); const live = this.liveCatalog.find( (candidate) => candidate.localName === tool.localName, ); if ( this.state !== "connected" || !this.connection || !live || live.name !== tool.name ) { end?.("error", { type: "unavailable" }); throw new Error("IntelliJ MCP tool is disconnected or stale."); } if (!this.projectPath || !samePath(ctx.cwd, this.projectPath)) { end?.("error", { type: "routing" }); throw new Error("IntelliJ MCP tool is routed outside this session cwd."); } const args = { ...params }; if (tool.acceptsProjectPath) args.projectPath = ctx.cwd; else delete args.projectPath; let result: McpResult; try { result = await this.connection.callTool( tool.name, args, signal, (progress) => onUpdate?.({ content: [ { type: "text", text: boundedText(`IntelliJ progress: ${safeJson(progress)}`), }, ], details: { source: "intellij", tool: tool.name }, }), ); } catch (error) { end?.("error", { type: "remote" }); throw toolFailure( tool, args, ctx.cwd, error instanceof Error ? error.message : String(error), ); } if (result.isError || semanticFailure(result)) { end?.("error", { type: "remote" }); throw toolFailure( tool, args, ctx.cwd, resultText(result) || "The IntelliJ operation failed without details.", ); } end?.("finish", { status: "ok" }); return mapResult(result, tool.name); } private async disconnect(): Promise { this.generation++; this.pendingAbort?.abort(new Error("IntelliJ MCP operation cancelled.")); this.pendingAbort = undefined; await this.closeConnection(); this.deactivateTools(); this.liveCatalog = []; this.state = "disconnected"; this.projectPath = undefined; } private async closeConnection(): Promise { const connection = this.connection; this.connection = undefined; if (connection) await connection.close().catch(() => {}); } private deactivateTools(): void { this.pi.setActiveTools( this.pi.getActiveTools().filter((name) => !this.ownedNames.has(name)), ); } private fail( ctx: ExtensionContext, error: unknown, endpoint?: string, staleSavedUrl = false, ): void { this.state = "failed"; if (endpoint) this.endpoint = endpoint; setConnectionStatus(ctx, "failed"); const message = error instanceof Error ? error.message : String(error); ctx.ui.notify( `IntelliJ MCP failed${endpoint ? ` at ${endpoint}` : ""}: ${message}${ staleSavedUrl ? " The saved URL is stale." : "" } ${RECOVERY}`, "error", ); } private showStatus(ctx: ExtensionContext): void { let endpoint = this.endpoint; let settingError: string | undefined; if (!endpoint) { const saved = resolveSetting(this.pi, ctx, URL_SETTING); if (saved.ok) endpoint = saved.value; else settingError = saved.error; } ctx.ui.notify( [ `IntelliJ MCP: ${this.state}`, settingError ? `endpoint: unavailable (${settingError})` : `endpoint: ${endpoint ?? "none"}`, `project: ${this.projectPath ?? ctx.cwd}`, `active tools: ${ this.pi.getActiveTools().filter((name) => this.ownedNames.has(name)) .length }`, ].join("\n"), settingError ? "warning" : "info", ); } private showTools(ctx: ExtensionContext): void { const active = new Set(this.pi.getActiveTools()); const lines = this.catalog.map( (tool) => `${active.has(tool.localName) && this.state === "connected" ? "live" : "stale"} ${tool.localName} ← ${tool.name}`, ); ctx.ui.notify( lines.length > 0 ? lines.join("\n") : "No IntelliJ MCP catalog fetched in this session.", "info", ); } } export function createIntellijRuntime(pi: ExtensionAPI): IntellijRuntime { return new Runtime(pi); } export const __test = { acceptsProjectPath, localBase, mapResult, normalizeIntellijUrl, validateRemoteTool, };