repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/intellij/implementation.ts
Rawimport { 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<string, unknown>;
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<RemoteTool[]>;
callTool(
name: string,
args: JsonObject,
signal: AbortSignal | undefined,
onProgress?: (progress: unknown) => void,
): Promise<McpResult>;
close(): Promise<void>;
onClose(handler: () => void): void;
}
export interface IntellijRuntime {
run(
command: IntellijCommand,
argument: string,
ctx: ExtensionCommandContext,
): Promise<void>;
shutdown(): Promise<void>;
}
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 <url>.";
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<string>,
): 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<string>,
): 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<JsonObject>();
const visited = new Set<JsonObject>();
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<IdeaLauncher | undefined> {
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<void> {
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<void>((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<string | undefined> = {
key: "intellij.url",
parse: (raw) => (typeof raw === "string" ? raw : undefined),
default: undefined,
};
export async function persistUrl(url: string): Promise<void> {
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<McpConnection> {
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<void> {
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<void> {
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<ConnectionResult> {
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<void> {
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<ConnectionResult> {
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<string>();
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<string>();
private readonly localByRemote = new Map<string, string>();
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<void> {
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<void> {
await this.disconnect();
}
private async connect(
argument: string,
ctx: ExtensionCommandContext,
allowLaunch: boolean,
): Promise<void> {
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<void> {
await this.connect("", ctx, false);
}
private async activate(
connection: McpConnection,
tools: RemoteTool[],
ctx: ExtensionCommandContext,
generation: number,
): Promise<void> {
const names = new Set(this.pi.getAllTools().map((tool) => tool.name));
const seenRemote = new Set<string>();
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<void> {
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<void> {
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,
};