Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ argent init
| Zed | `.zed/settings.json` |
| Gemini CLI | `.gemini/settings.json` |
| Codex CLI | `.codex/config.yaml` |
| JetBrains | `.idea/mcp.json` |

## Privacy

Expand Down
53 changes: 53 additions & 0 deletions packages/mcp/src/cli/mcp-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,58 @@ const codexAdapter: McpConfigAdapter = {
},
};

// ── JetBrains adapter ───────────────────────────────────────────────────────
// Format: { mcpServers: { argent: { command, args, env } } }
// 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 {
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 {
return path.join(root, ".idea", "mcp.json");
},

globalPath(): string | null {
return null;
},

write(configPath: string, entry: McpServerEntry): void {
const config = readJson(configPath);
const servers = (config.mcpServers ?? {}) as Record<string, unknown>;
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<string, unknown> | undefined;
if (!servers?.[MCP_SERVER_KEY]) return false;
delete servers[MCP_SERVER_KEY];
writeJsonOrRemove(configPath, config);
return true;
},
};

// ── Registry ──────────────────────────────────────────────────────────────────
// MARK: Registry

Expand All @@ -615,6 +667,7 @@ export const ALL_ADAPTERS: McpConfigAdapter[] = [
zedAdapter,
geminiAdapter,
codexAdapter,
jetbrainsAdapter,
];

export function detectAdapters(): McpConfigAdapter[] {
Expand Down
125 changes: 124 additions & 1 deletion packages/mcp/test/cli/mcp-configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,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",
Expand All @@ -86,6 +86,7 @@ describe("ALL_ADAPTERS", () => {
"Zed",
"Gemini",
"Codex",
"JetBrains",
]);
});
});
Expand Down Expand Up @@ -519,6 +520,128 @@ describe("Codex adapter", () => {
});
});

// ── JetBrains 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");
adapter.write(configPath, getMcpEntry());

const config = readJsonFile(configPath);
const servers = config.mcpServers as Record<string, unknown>;
expect(servers).toHaveProperty("argent");
const argent = servers.argent as Record<string, unknown>;
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);
expect(fs.existsSync(configPath)).toBe(false);
});

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<string, unknown>;
expect(servers).toHaveProperty("other");
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<string, unknown>;
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"));
});

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);
if (!existed) fs.mkdirSync(localIdea, { recursive: true });
try {
expect(adapter.detect()).toBe(true);
} finally {
if (!existed) fs.rmdirSync(localIdea);
}
});

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("detect() returns false on macOS when the JetBrains base dir has no subdirs", () => {
homedirOverride = tmpDir;
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 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);
});
});

// ── Claude permissions ────────────────────────────────────────────────────────

describe("addClaudePermission / removeClaudePermission", () => {
Expand Down
Loading