From 5445e18b47ca63e0999a654feaa67083ead940c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 9 Apr 2026 16:39:03 +0200 Subject: [PATCH 1/4] feat: add MCP config adapter for JetBrains IDEs Add a JetBrains adapter to the MCP configuration system that supports IntelliJ IDEA, WebStorm, PyCharm, GoLand, Rider, CLion, PhpStorm, RubyMine, DataGrip, RustRover, Aqua, DataSpell, Fleet, and Android Studio. The adapter detects JetBrains installations by scanning the platform- specific config directory (~/Library/Application Support/JetBrains on macOS, ~/.config/JetBrains on Linux) for known product prefixes. It also detects local .idea project directories. Project-level config writes to .idea/mcp.json. Global config targets the most recent product version directory's options/mcp.json. --- packages/mcp/src/cli/mcp-configs.ts | 107 +++++++++++++++++++ packages/mcp/test/cli/mcp-configs.test.ts | 124 +++++++++++++++++++++- 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/cli/mcp-configs.ts b/packages/mcp/src/cli/mcp-configs.ts index 682f8804..128cba14 100644 --- a/packages/mcp/src/cli/mcp-configs.ts +++ b/packages/mcp/src/cli/mcp-configs.ts @@ -466,6 +466,112 @@ const codexAdapter: McpConfigAdapter = { }, }; +// ── JetBrains adapter ─────────────────────────────────────────────────────── +// Format: { mcpServers: { argent: { command, args, env } } } +// Project: .idea/mcp.json Global: ~/Library/Application Support/JetBrains//options/mcp.json (macOS) +// ~/.config/JetBrains//options/mcp.json (Linux) + +/** JetBrains product directory prefixes (each has a version suffix like "2025.1"). */ +const JETBRAINS_PRODUCTS = [ + "IntelliJIdea", + "WebStorm", + "PyCharm", + "PyCharmCE", + "GoLand", + "Rider", + "CLion", + "PhpStorm", + "RubyMine", + "DataGrip", + "RustRover", + "Aqua", + "DataSpell", + "Fleet", + "AndroidStudio", +]; + +/** + * Return the JetBrains base config directory for the current platform. + * macOS: ~/Library/Application Support/JetBrains + * Linux: ~/.config/JetBrains + */ +function jetbrainsBaseDir(): string { + if (process.platform === "darwin") { + return path.join(homedir(), "Library", "Application Support", "JetBrains"); + } + // Linux (and fallback) + return path.join(homedir(), ".config", "JetBrains"); +} + +/** + * Find all existing JetBrains product config directories that contain an + * `options` subdirectory (the standard location for settings like mcp.json). + * Returns paths sorted by name (newest version last for a given product). + */ +function findJetbrainsConfigDirs(): string[] { + const base = jetbrainsBaseDir(); + if (!dirExists(base)) return []; + try { + const entries = fs.readdirSync(base, { withFileTypes: true }); + return entries + .filter( + (e) => + e.isDirectory() && + JETBRAINS_PRODUCTS.some((p) => e.name.startsWith(p)) + ) + .map((e) => path.join(base, e.name)) + .sort(); + } catch { + return []; + } +} + +const jetbrainsAdapter: McpConfigAdapter = { + name: "JetBrains", + + detect(): boolean { + // Detect via global config directories or a local .idea directory + return ( + findJetbrainsConfigDirs().length > 0 || + dirExists(path.join(process.cwd(), ".idea")) + ); + }, + + projectPath(root: string): string | null { + return path.join(root, ".idea", "mcp.json"); + }, + + globalPath(): string | null { + // Return the mcp.json path inside the most recent (last sorted) product dir. + // When no product dirs exist, return null. + const dirs = findJetbrainsConfigDirs(); + if (dirs.length === 0) return null; + return path.join(dirs[dirs.length - 1], "options", "mcp.json"); + }, + + write(configPath: string, entry: McpServerEntry): void { + const config = readJson(configPath); + const servers = (config.mcpServers ?? {}) as Record; + servers[MCP_SERVER_KEY] = { + command: entry.command, + args: entry.args, + env: entry.env, + }; + config.mcpServers = servers; + writeJson(configPath, config); + }, + + remove(configPath: string): boolean { + if (!fs.existsSync(configPath)) return false; + const config = readJson(configPath); + const servers = config.mcpServers as Record | undefined; + if (!servers?.[MCP_SERVER_KEY]) return false; + delete servers[MCP_SERVER_KEY]; + writeJson(configPath, config); + return true; + }, +}; + // ── Registry ────────────────────────────────────────────────────────────────── export const ALL_ADAPTERS: McpConfigAdapter[] = [ @@ -476,6 +582,7 @@ export const ALL_ADAPTERS: McpConfigAdapter[] = [ zedAdapter, geminiAdapter, codexAdapter, + jetbrainsAdapter, ]; export function detectAdapters(): McpConfigAdapter[] { diff --git a/packages/mcp/test/cli/mcp-configs.test.ts b/packages/mcp/test/cli/mcp-configs.test.ts index 03ef58c8..f274eab4 100644 --- a/packages/mcp/test/cli/mcp-configs.test.ts +++ b/packages/mcp/test/cli/mcp-configs.test.ts @@ -63,7 +63,7 @@ describe("getMcpEntry", () => { // ── Adapter registry ────────────────────────────────────────────────────────── describe("ALL_ADAPTERS", () => { - it("contains all seven adapters", () => { + it("contains all eight adapters", () => { const names = ALL_ADAPTERS.map((a) => a.name); expect(names).toEqual([ "Cursor", @@ -73,6 +73,7 @@ describe("ALL_ADAPTERS", () => { "Zed", "Gemini", "Codex", + "JetBrains", ]); }); }); @@ -460,6 +461,127 @@ describe("Codex adapter", () => { }); }); +// ── JetBrains adapter ──────────────────────────────────────────────────────── + +describe("JetBrains adapter", () => { + const adapter = ALL_ADAPTERS.find((a) => a.name === "JetBrains")!; + + it("writes { mcpServers: { argent: ... } } format", () => { + const configPath = path.join(tmpDir, ".idea", "mcp.json"); + const entry = getMcpEntry(); + + adapter.write(configPath, entry); + + const config = readJsonFile(configPath); + const servers = config.mcpServers as Record; + expect(servers).toHaveProperty("argent"); + const argent = servers.argent as Record; + expect(argent.command).toBe("argent"); + expect(argent).not.toHaveProperty("type"); + }); + + it("removes argent entry and returns true", () => { + const configPath = path.join(tmpDir, ".idea", "mcp.json"); + adapter.write(configPath, getMcpEntry()); + + const removed = adapter.remove(configPath); + expect(removed).toBe(true); + + const config = readJsonFile(configPath); + const servers = config.mcpServers as Record; + expect(servers).not.toHaveProperty("argent"); + }); + + it("returns false when removing from non-existent file", () => { + expect(adapter.remove(path.join(tmpDir, "nope.json"))).toBe(false); + }); + + it("returns false when removing from file without argent entry", () => { + const configPath = path.join(tmpDir, ".idea", "mcp.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); + + expect(adapter.remove(configPath)).toBe(false); + }); + + it("preserves other servers when writing", () => { + const configPath = path.join(tmpDir, ".idea", "mcp.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "other" } } })); + + adapter.write(configPath, getMcpEntry()); + + const config = readJsonFile(configPath); + const servers = config.mcpServers as Record; + expect(servers).toHaveProperty("other"); + expect(servers).toHaveProperty("argent"); + }); + + it("projectPath returns .idea/mcp.json under project root", () => { + expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".idea", "mcp.json")); + }); + + it("detect() returns true when local .idea dir exists", () => { + const localIdea = path.join(process.cwd(), ".idea"); + const existed = fs.existsSync(localIdea); + if (!existed) fs.mkdirSync(localIdea, { recursive: true }); + try { + expect(adapter.detect()).toBe(true); + } finally { + if (!existed) fs.rmdirSync(localIdea); + } + }); + + it("detect() returns true when JetBrains global config dir exists", () => { + homedirOverride = tmpDir; + const jbBase = + process.platform === "darwin" + ? path.join(tmpDir, "Library", "Application Support", "JetBrains") + : path.join(tmpDir, ".config", "JetBrains"); + const productDir = path.join(jbBase, "WebStorm2025.1"); + fs.mkdirSync(productDir, { recursive: true }); + + try { + expect(adapter.detect()).toBe(true); + } finally { + homedirOverride = undefined; + } + }); + + it("globalPath returns mcp.json inside the most recent product dir", () => { + homedirOverride = tmpDir; + const jbBase = + process.platform === "darwin" + ? path.join(tmpDir, "Library", "Application Support", "JetBrains") + : path.join(tmpDir, ".config", "JetBrains"); + + // Create two product dirs — globalPath should pick the last sorted one + fs.mkdirSync(path.join(jbBase, "WebStorm2024.3"), { recursive: true }); + fs.mkdirSync(path.join(jbBase, "WebStorm2025.1"), { recursive: true }); + + try { + expect(adapter.globalPath()).toBe( + path.join(jbBase, "WebStorm2025.1", "options", "mcp.json") + ); + } finally { + homedirOverride = undefined; + } + }); + + it("globalPath returns null when no JetBrains config dirs exist", () => { + homedirOverride = tmpDir; + try { + expect(adapter.globalPath()).toBeNull(); + } finally { + homedirOverride = undefined; + } + }); + + afterEach(() => { + homedirOverride = undefined; + }); +}); + // ── Claude permissions ──────────────────────────────────────────────────────── describe("addClaudePermission / removeClaudePermission", () => { From 5c43d0908bb4fd4022250781b9fd373057b81be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 18:35:38 +0200 Subject: [PATCH 2/4] fix: review findings on JetBrains MCP adapter - remove() now uses writeJsonOrRemove so empty configs are deleted (matches peer adapters; unblocks generic uninstall test) - globalPath() picks newest product version with numeric parsing so 2025.10 > 2025.2 across all detected products - Product matcher requires a digit right after the prefix to prevent collisions (PyCharm vs PyCharmCE) and ignores non-versioned dirs - Scope detection to macOS only - Add JetBrains row to the Supported Editors table --- README.md | 1 + packages/mcp/src/cli/mcp-configs.ts | 85 ++++++++++------- packages/mcp/test/cli/mcp-configs.test.ts | 111 ++++++++++++++-------- 3 files changed, 125 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index c63486a8..50cba8c6 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ argent init | Zed | `.zed/settings.json` | | Gemini CLI | `.gemini/settings.json` | | Codex CLI | `.codex/config.yaml` | +| JetBrains | `.idea/mcp.json` (project) or `~/Library/Application Support/JetBrains//options/mcp.json` (global, macOS) | ## Privacy diff --git a/packages/mcp/src/cli/mcp-configs.ts b/packages/mcp/src/cli/mcp-configs.ts index 0e86f237..6c333408 100644 --- a/packages/mcp/src/cli/mcp-configs.ts +++ b/packages/mcp/src/cli/mcp-configs.ts @@ -606,8 +606,8 @@ const codexAdapter: McpConfigAdapter = { // ── JetBrains adapter ─────────────────────────────────────────────────────── // Format: { mcpServers: { argent: { command, args, env } } } -// Project: .idea/mcp.json Global: ~/Library/Application Support/JetBrains//options/mcp.json (macOS) -// ~/.config/JetBrains//options/mcp.json (Linux) +// Project: .idea/mcp.json +// Global (macOS only): ~/Library/Application Support/JetBrains//options/mcp.json /** JetBrains product directory prefixes (each has a version suffix like "2025.1"). */ const JETBRAINS_PRODUCTS = [ @@ -628,37 +628,57 @@ const JETBRAINS_PRODUCTS = [ "AndroidStudio", ]; +interface JetbrainsProductDir { + path: string; + version: number[]; +} + +function jetbrainsBaseDir(): string | null { + if (process.platform !== "darwin") return null; + return path.join(homedir(), "Library", "Application Support", "JetBrains"); +} + /** - * Return the JetBrains base config directory for the current platform. - * macOS: ~/Library/Application Support/JetBrains - * Linux: ~/.config/JetBrains + * A product directory name is a known product prefix followed immediately by + * a version number (e.g. "IntelliJIdea2025.1"). The digit requirement prevents + * collisions like "PyCharm" matching "PyCharmCE2025.1" or missing "2025.10". */ -function jetbrainsBaseDir(): string { - if (process.platform === "darwin") { - return path.join(homedir(), "Library", "Application Support", "JetBrains"); +function matchProductDir(name: string): JetbrainsProductDir | null { + for (const prefix of JETBRAINS_PRODUCTS) { + if (name.length <= prefix.length) continue; + if (!name.startsWith(prefix)) continue; + const next = name.charCodeAt(prefix.length); + if (next < 48 || next > 57) continue; // next char must be a digit + const version = name + .slice(prefix.length) + .split(".") + .map((s) => parseInt(s, 10)); + return { path: name, version }; } - // Linux (and fallback) - return path.join(homedir(), ".config", "JetBrains"); + return null; } -/** - * Find all existing JetBrains product config directories that contain an - * `options` subdirectory (the standard location for settings like mcp.json). - * Returns paths sorted by name (newest version last for a given product). - */ -function findJetbrainsConfigDirs(): string[] { +function compareVersions(a: number[], b: number[]): number { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function findJetbrainsProductDirs(): JetbrainsProductDir[] { const base = jetbrainsBaseDir(); - if (!dirExists(base)) return []; + if (!base || !dirExists(base)) return []; try { - const entries = fs.readdirSync(base, { withFileTypes: true }); - return entries - .filter( - (e) => - e.isDirectory() && - JETBRAINS_PRODUCTS.some((p) => e.name.startsWith(p)) - ) - .map((e) => path.join(base, e.name)) - .sort(); + const result: JetbrainsProductDir[] = []; + for (const entry of fs.readdirSync(base, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const match = matchProductDir(entry.name); + if (!match) continue; + result.push({ path: path.join(base, entry.name), version: match.version }); + } + return result; } catch { return []; } @@ -668,10 +688,8 @@ const jetbrainsAdapter: McpConfigAdapter = { name: "JetBrains", detect(): boolean { - // Detect via global config directories or a local .idea directory return ( - findJetbrainsConfigDirs().length > 0 || - dirExists(path.join(process.cwd(), ".idea")) + findJetbrainsProductDirs().length > 0 || dirExists(path.join(process.cwd(), ".idea")) ); }, @@ -680,11 +698,10 @@ const jetbrainsAdapter: McpConfigAdapter = { }, globalPath(): string | null { - // Return the mcp.json path inside the most recent (last sorted) product dir. - // When no product dirs exist, return null. - const dirs = findJetbrainsConfigDirs(); + const dirs = findJetbrainsProductDirs(); if (dirs.length === 0) return null; - return path.join(dirs[dirs.length - 1], "options", "mcp.json"); + const newest = dirs.reduce((a, b) => (compareVersions(a.version, b.version) >= 0 ? a : b)); + return path.join(newest.path, "options", "mcp.json"); }, write(configPath: string, entry: McpServerEntry): void { @@ -705,7 +722,7 @@ const jetbrainsAdapter: McpConfigAdapter = { const servers = config.mcpServers as Record | undefined; if (!servers?.[MCP_SERVER_KEY]) return false; delete servers[MCP_SERVER_KEY]; - writeJson(configPath, config); + writeJsonOrRemove(configPath, config); return true; }, }; diff --git a/packages/mcp/test/cli/mcp-configs.test.ts b/packages/mcp/test/cli/mcp-configs.test.ts index 98c81e0f..acc4e787 100644 --- a/packages/mcp/test/cli/mcp-configs.test.ts +++ b/packages/mcp/test/cli/mcp-configs.test.ts @@ -524,6 +524,18 @@ describe("Codex adapter", () => { describe("JetBrains adapter", () => { const adapter = ALL_ADAPTERS.find((a) => a.name === "JetBrains")!; + const originalPlatform = process.platform; + const macJbBase = (home: string): string => + path.join(home, "Library", "Application Support", "JetBrains"); + + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + homedirOverride = undefined; + }); it("writes { mcpServers: { argent: ... } } format", () => { const configPath = path.join(tmpDir, ".idea", "mcp.json"); @@ -545,10 +557,7 @@ describe("JetBrains adapter", () => { const removed = adapter.remove(configPath); expect(removed).toBe(true); - - const config = readJsonFile(configPath); - const servers = config.mcpServers as Record; - expect(servers).not.toHaveProperty("argent"); + expect(fs.existsSync(configPath)).toBe(false); }); it("returns false when removing from non-existent file", () => { @@ -576,6 +585,23 @@ describe("JetBrains adapter", () => { expect(servers).toHaveProperty("argent"); }); + it("remove preserves sibling servers and keeps the file", () => { + const configPath = path.join(tmpDir, ".idea", "mcp.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ mcpServers: { other: { command: "other" } } }) + ); + adapter.write(configPath, getMcpEntry()); + + expect(adapter.remove(configPath)).toBe(true); + + const config = readJsonFile(configPath); + const servers = config.mcpServers as Record; + expect(servers).toHaveProperty("other"); + expect(servers).not.toHaveProperty("argent"); + }); + it("projectPath returns .idea/mcp.json under project root", () => { expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".idea", "mcp.json")); }); @@ -591,53 +617,62 @@ describe("JetBrains adapter", () => { } }); - it("detect() returns true when JetBrains global config dir exists", () => { + it("detect() returns true when a JetBrains product dir exists under ~/Library", () => { homedirOverride = tmpDir; - const jbBase = - process.platform === "darwin" - ? path.join(tmpDir, "Library", "Application Support", "JetBrains") - : path.join(tmpDir, ".config", "JetBrains"); - const productDir = path.join(jbBase, "WebStorm2025.1"); - fs.mkdirSync(productDir, { recursive: true }); + fs.mkdirSync(path.join(macJbBase(tmpDir), "WebStorm2025.1"), { recursive: true }); + expect(adapter.detect()).toBe(true); + }); - try { - expect(adapter.detect()).toBe(true); - } finally { - homedirOverride = undefined; - } + it("globalPath picks the newest version across products (numeric sort)", () => { + homedirOverride = tmpDir; + const base = macJbBase(tmpDir); + // 2025.2 > 2025.10 lexicographically, but 2025.10 > 2025.2 numerically. + fs.mkdirSync(path.join(base, "WebStorm2025.2"), { recursive: true }); + fs.mkdirSync(path.join(base, "WebStorm2025.10"), { recursive: true }); + fs.mkdirSync(path.join(base, "IntelliJIdea2024.3"), { recursive: true }); + + expect(adapter.globalPath()).toBe( + path.join(base, "WebStorm2025.10", "options", "mcp.json") + ); }); - it("globalPath returns mcp.json inside the most recent product dir", () => { + it("globalPath is not fooled by prefix collisions (PyCharm vs PyCharmCE)", () => { homedirOverride = tmpDir; - const jbBase = - process.platform === "darwin" - ? path.join(tmpDir, "Library", "Application Support", "JetBrains") - : path.join(tmpDir, ".config", "JetBrains"); + const base = macJbBase(tmpDir); + fs.mkdirSync(path.join(base, "PyCharmCE2025.1"), { recursive: true }); + fs.mkdirSync(path.join(base, "PyCharm2024.3"), { recursive: true }); - // Create two product dirs — globalPath should pick the last sorted one - fs.mkdirSync(path.join(jbBase, "WebStorm2024.3"), { recursive: true }); - fs.mkdirSync(path.join(jbBase, "WebStorm2025.1"), { recursive: true }); + // PyCharmCE2025.1 must be treated as PyCharmCE (not PyCharm with a CE2025.1 + // version), and must win by numeric version. + expect(adapter.globalPath()).toBe( + path.join(base, "PyCharmCE2025.1", "options", "mcp.json") + ); + }); - try { - expect(adapter.globalPath()).toBe( - path.join(jbBase, "WebStorm2025.1", "options", "mcp.json") - ); - } finally { - homedirOverride = undefined; - } + it("globalPath ignores directories without a numeric version suffix", () => { + homedirOverride = tmpDir; + const base = macJbBase(tmpDir); + // Fleet on disk looks like "Fleet" (no version suffix) — must not match. + fs.mkdirSync(path.join(base, "Fleet"), { recursive: true }); + // Also an unrelated dir that happens to share a prefix. + fs.mkdirSync(path.join(base, "PyCharmCustomPlugin"), { recursive: true }); + + expect(adapter.globalPath()).toBeNull(); }); it("globalPath returns null when no JetBrains config dirs exist", () => { homedirOverride = tmpDir; - try { - expect(adapter.globalPath()).toBeNull(); - } finally { - homedirOverride = undefined; - } + expect(adapter.globalPath()).toBeNull(); }); - afterEach(() => { - homedirOverride = undefined; + it("detect() ignores the JetBrains dir on non-macOS platforms", () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + homedirOverride = tmpDir; + fs.mkdirSync(path.join(macJbBase(tmpDir), "WebStorm2025.1"), { recursive: true }); + + // No local .idea here (tmpDir is not cwd), so detect() must be false. + expect(adapter.detect()).toBe(false); + expect(adapter.globalPath()).toBeNull(); }); }); From f9d8e0f1ff1f90085a10c4469b2bc06983b52398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 18:52:27 +0200 Subject: [PATCH 3/4] refactor: make JetBrains adapter project-only, drop Fleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .idea/ is shared by every IntelliJ-platform IDE (IntelliJ, WebStorm, PyCharm, GoLand, Rider, CLion, PhpStorm, RubyMine, DataGrip, RustRover, Aqua, DataSpell, Android Studio), so a single .idea/mcp.json covers them all — no need to scan per-product global dirs or fan out writes. Fleet uses a different layout entirely and is dropped. Mirrors the VS Code adapter shape: globalPath() returns null and init.ts already falls back to the project path for project-only adapters. --- README.md | 2 +- packages/mcp/src/cli/mcp-configs.ts | 97 +++-------------------- packages/mcp/test/cli/mcp-configs.test.ts | 59 ++++---------- 3 files changed, 28 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 50cba8c6..10297ca0 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ argent init | Zed | `.zed/settings.json` | | Gemini CLI | `.gemini/settings.json` | | Codex CLI | `.codex/config.yaml` | -| JetBrains | `.idea/mcp.json` (project) or `~/Library/Application Support/JetBrains//options/mcp.json` (global, macOS) | +| JetBrains | `.idea/mcp.json` | ## Privacy diff --git a/packages/mcp/src/cli/mcp-configs.ts b/packages/mcp/src/cli/mcp-configs.ts index 6c333408..30c23fa1 100644 --- a/packages/mcp/src/cli/mcp-configs.ts +++ b/packages/mcp/src/cli/mcp-configs.ts @@ -606,91 +606,23 @@ const codexAdapter: McpConfigAdapter = { // ── JetBrains adapter ─────────────────────────────────────────────────────── // Format: { mcpServers: { argent: { command, args, env } } } -// Project: .idea/mcp.json -// Global (macOS only): ~/Library/Application Support/JetBrains//options/mcp.json - -/** JetBrains product directory prefixes (each has a version suffix like "2025.1"). */ -const JETBRAINS_PRODUCTS = [ - "IntelliJIdea", - "WebStorm", - "PyCharm", - "PyCharmCE", - "GoLand", - "Rider", - "CLion", - "PhpStorm", - "RubyMine", - "DataGrip", - "RustRover", - "Aqua", - "DataSpell", - "Fleet", - "AndroidStudio", -]; - -interface JetbrainsProductDir { - path: string; - version: number[]; -} - -function jetbrainsBaseDir(): string | null { - if (process.platform !== "darwin") return null; - return path.join(homedir(), "Library", "Application Support", "JetBrains"); -} - -/** - * A product directory name is a known product prefix followed immediately by - * a version number (e.g. "IntelliJIdea2025.1"). The digit requirement prevents - * collisions like "PyCharm" matching "PyCharmCE2025.1" or missing "2025.10". - */ -function matchProductDir(name: string): JetbrainsProductDir | null { - for (const prefix of JETBRAINS_PRODUCTS) { - if (name.length <= prefix.length) continue; - if (!name.startsWith(prefix)) continue; - const next = name.charCodeAt(prefix.length); - if (next < 48 || next > 57) continue; // next char must be a digit - const version = name - .slice(prefix.length) - .split(".") - .map((s) => parseInt(s, 10)); - return { path: name, version }; - } - return null; -} - -function compareVersions(a: number[], b: number[]): number { - const len = Math.max(a.length, b.length); - for (let i = 0; i < len; i++) { - const diff = (a[i] ?? 0) - (b[i] ?? 0); - if (diff !== 0) return diff; - } - return 0; -} - -function findJetbrainsProductDirs(): JetbrainsProductDir[] { - const base = jetbrainsBaseDir(); - if (!base || !dirExists(base)) return []; - try { - const result: JetbrainsProductDir[] = []; - for (const entry of fs.readdirSync(base, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const match = matchProductDir(entry.name); - if (!match) continue; - result.push({ path: path.join(base, entry.name), version: match.version }); - } - return result; - } catch { - return []; - } -} +// Project only: .idea/mcp.json — shared by every IntelliJ-platform IDE +// (IntelliJ, WebStorm, PyCharm, GoLand, Rider, CLion, PhpStorm, RubyMine, +// DataGrip, RustRover, Aqua, DataSpell, Android Studio). Fleet is not supported. const jetbrainsAdapter: McpConfigAdapter = { name: "JetBrains", detect(): boolean { - return ( - findJetbrainsProductDirs().length > 0 || dirExists(path.join(process.cwd(), ".idea")) - ); + if (dirExists(path.join(process.cwd(), ".idea"))) return true; + if (process.platform !== "darwin") return false; + const base = path.join(homedir(), "Library", "Application Support", "JetBrains"); + if (!dirExists(base)) return false; + try { + return fs.readdirSync(base, { withFileTypes: true }).some((e) => e.isDirectory()); + } catch { + return false; + } }, projectPath(root: string): string | null { @@ -698,10 +630,7 @@ const jetbrainsAdapter: McpConfigAdapter = { }, globalPath(): string | null { - const dirs = findJetbrainsProductDirs(); - if (dirs.length === 0) return null; - const newest = dirs.reduce((a, b) => (compareVersions(a.version, b.version) >= 0 ? a : b)); - return path.join(newest.path, "options", "mcp.json"); + return null; }, write(configPath: string, entry: McpServerEntry): void { diff --git a/packages/mcp/test/cli/mcp-configs.test.ts b/packages/mcp/test/cli/mcp-configs.test.ts index acc4e787..c0931ae7 100644 --- a/packages/mcp/test/cli/mcp-configs.test.ts +++ b/packages/mcp/test/cli/mcp-configs.test.ts @@ -539,9 +539,7 @@ describe("JetBrains adapter", () => { it("writes { mcpServers: { argent: ... } } format", () => { const configPath = path.join(tmpDir, ".idea", "mcp.json"); - const entry = getMcpEntry(); - - adapter.write(configPath, entry); + adapter.write(configPath, getMcpEntry()); const config = readJsonFile(configPath); const servers = config.mcpServers as Record; @@ -606,6 +604,12 @@ describe("JetBrains adapter", () => { expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".idea", "mcp.json")); }); + it("globalPath is always null (project-only adapter)", () => { + homedirOverride = tmpDir; + fs.mkdirSync(path.join(macJbBase(tmpDir), "WebStorm2025.1"), { recursive: true }); + expect(adapter.globalPath()).toBeNull(); + }); + it("detect() returns true when local .idea dir exists", () => { const localIdea = path.join(process.cwd(), ".idea"); const existed = fs.existsSync(localIdea); @@ -617,62 +621,27 @@ describe("JetBrains adapter", () => { } }); - it("detect() returns true when a JetBrains product dir exists under ~/Library", () => { + it("detect() returns true on macOS when ~/Library/.../JetBrains has any product dir", () => { homedirOverride = tmpDir; fs.mkdirSync(path.join(macJbBase(tmpDir), "WebStorm2025.1"), { recursive: true }); expect(adapter.detect()).toBe(true); }); - it("globalPath picks the newest version across products (numeric sort)", () => { - homedirOverride = tmpDir; - const base = macJbBase(tmpDir); - // 2025.2 > 2025.10 lexicographically, but 2025.10 > 2025.2 numerically. - fs.mkdirSync(path.join(base, "WebStorm2025.2"), { recursive: true }); - fs.mkdirSync(path.join(base, "WebStorm2025.10"), { recursive: true }); - fs.mkdirSync(path.join(base, "IntelliJIdea2024.3"), { recursive: true }); - - expect(adapter.globalPath()).toBe( - path.join(base, "WebStorm2025.10", "options", "mcp.json") - ); - }); - - it("globalPath is not fooled by prefix collisions (PyCharm vs PyCharmCE)", () => { + it("detect() returns false on macOS when the JetBrains base dir has no subdirs", () => { homedirOverride = tmpDir; - const base = macJbBase(tmpDir); - fs.mkdirSync(path.join(base, "PyCharmCE2025.1"), { recursive: true }); - fs.mkdirSync(path.join(base, "PyCharm2024.3"), { recursive: true }); - - // PyCharmCE2025.1 must be treated as PyCharmCE (not PyCharm with a CE2025.1 - // version), and must win by numeric version. - expect(adapter.globalPath()).toBe( - path.join(base, "PyCharmCE2025.1", "options", "mcp.json") - ); - }); - - it("globalPath ignores directories without a numeric version suffix", () => { - homedirOverride = tmpDir; - const base = macJbBase(tmpDir); - // Fleet on disk looks like "Fleet" (no version suffix) — must not match. - fs.mkdirSync(path.join(base, "Fleet"), { recursive: true }); - // Also an unrelated dir that happens to share a prefix. - fs.mkdirSync(path.join(base, "PyCharmCustomPlugin"), { recursive: true }); - - expect(adapter.globalPath()).toBeNull(); - }); - - it("globalPath returns null when no JetBrains config dirs exist", () => { - homedirOverride = tmpDir; - expect(adapter.globalPath()).toBeNull(); + fs.mkdirSync(macJbBase(tmpDir), { recursive: true }); + // A stray file must not be treated as an install signal. + fs.writeFileSync(path.join(macJbBase(tmpDir), ".DS_Store"), ""); + expect(adapter.detect()).toBe(false); }); - it("detect() ignores the JetBrains dir on non-macOS platforms", () => { + it("detect() ignores the JetBrains base dir on non-macOS platforms", () => { Object.defineProperty(process, "platform", { value: "linux", configurable: true }); homedirOverride = tmpDir; fs.mkdirSync(path.join(macJbBase(tmpDir), "WebStorm2025.1"), { recursive: true }); // No local .idea here (tmpDir is not cwd), so detect() must be false. expect(adapter.detect()).toBe(false); - expect(adapter.globalPath()).toBeNull(); }); }); From a41f3f212537c7b8f54ba136df9a646ba6b6e13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Fri, 17 Apr 2026 19:29:35 +0200 Subject: [PATCH 4/4] style: apply prettier formatting --- packages/mcp/test/cli/mcp-configs.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/mcp/test/cli/mcp-configs.test.ts b/packages/mcp/test/cli/mcp-configs.test.ts index c0931ae7..b18e2ebf 100644 --- a/packages/mcp/test/cli/mcp-configs.test.ts +++ b/packages/mcp/test/cli/mcp-configs.test.ts @@ -586,10 +586,7 @@ describe("JetBrains adapter", () => { it("remove preserves sibling servers and keeps the file", () => { const configPath = path.join(tmpDir, ".idea", "mcp.json"); fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync( - configPath, - JSON.stringify({ mcpServers: { other: { command: "other" } } }) - ); + fs.writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "other" } } })); adapter.write(configPath, getMcpEntry()); expect(adapter.remove(configPath)).toBe(true);