diff --git a/package-lock.json b/package-lock.json index b24bfcf..824a2db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "rsyncwrapper": "^3.1.0", "sharp": "^0.34.5", "simple-git": "^3.31.1", + "smol-toml": "^1.6.0", "snowball-stemmers": "^0.6.0", "turndown": "^7.2.2", "uuid": "^13.0.0", @@ -4617,6 +4618,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -14628,6 +14630,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/snowball-stemmers": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/snowball-stemmers/-/snowball-stemmers-0.6.0.tgz", diff --git a/package.json b/package.json index 5277dc7..520f47b 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "rsyncwrapper": "^3.1.0", "sharp": "^0.34.5", "simple-git": "^3.31.1", + "smol-toml": "^1.6.0", "snowball-stemmers": "^0.6.0", "turndown": "^7.2.2", "uuid": "^13.0.0", diff --git a/src/main/engine/MCPAgentConfigEngine.ts b/src/main/engine/MCPAgentConfigEngine.ts index e42902c..e293b5c 100644 --- a/src/main/engine/MCPAgentConfigEngine.ts +++ b/src/main/engine/MCPAgentConfigEngine.ts @@ -1,17 +1,20 @@ /** * MCPAgentConfigEngine – adds the bDS MCP server entry to coding-agent config files. * - * Supports: Claude Code, GitHub Copilot (VS Code), Gemini CLI, OpenCode. - * Each agent has its own config file format; this engine reads, merges, and writes - * the appropriate JSON structure without overwriting existing entries. + * Supports: Claude Code, Claude Desktop, GitHub Copilot (VS Code), Gemini CLI, + * OpenCode, Mistral Vibe, OpenAI Codex. + * + * All agents use the stdio-based standalone CLI server (`bds-mcp.cjs`), so the + * app does NOT need to be running for coding agents to use MCP. */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import path from 'path'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; // ── Public types ───────────────────────────────────────────────────── -export type MCPAgentId = 'claude-code' | 'claude-desktop' | 'github-copilot' | 'gemini-cli' | 'opencode'; +export type MCPAgentId = 'claude-code' | 'claude-desktop' | 'github-copilot' | 'gemini-cli' | 'opencode' | 'mistral-vibe' | 'openai-codex'; export interface AgentDefinition { id: MCPAgentId; @@ -27,11 +30,10 @@ export interface AgentConfigResult { export interface MCPAgentConfigOptions { homeDir: string; platform: NodeJS.Platform; - mcpUrl: string; - /** Required when agentId is 'claude-desktop'; unused otherwise. */ - execPath?: string; - /** Required when agentId is 'claude-desktop'; unused otherwise. */ - scriptPath?: string; + /** Absolute path to the Electron executable (used as the stdio command). */ + execPath: string; + /** Absolute path to the bds-mcp.cjs script. */ + scriptPath: string; } // ── Agent definitions ──────────────────────────────────────────────── @@ -42,6 +44,8 @@ const AGENTS: AgentDefinition[] = [ { id: 'github-copilot', label: 'GitHub Copilot' }, { id: 'gemini-cli', label: 'Gemini CLI' }, { id: 'opencode', label: 'OpenCode' }, + { id: 'mistral-vibe', label: 'Mistral Vibe' }, + { id: 'openai-codex', label: 'OpenAI Codex' }, ]; const SERVER_NAME = 'bDS'; @@ -51,14 +55,12 @@ const SERVER_NAME = 'bDS'; export class MCPAgentConfigEngine { private readonly homeDir: string; private readonly platform: NodeJS.Platform; - private readonly mcpUrl: string; - private readonly execPath?: string; - private readonly scriptPath?: string; + private readonly execPath: string; + private readonly scriptPath: string; constructor(opts: MCPAgentConfigOptions) { this.homeDir = opts.homeDir; this.platform = opts.platform; - this.mcpUrl = opts.mcpUrl; this.execPath = opts.execPath; this.scriptPath = opts.scriptPath; } @@ -81,17 +83,27 @@ export class MCPAgentConfigEngine { return path.join(this.homeDir, '.gemini', 'settings.json'); case 'opencode': return path.join(this.homeDir, '.opencode.json'); + case 'mistral-vibe': + return path.join(this.homeDir, '.vibe', 'config.toml'); + case 'openai-codex': + return path.join(this.homeDir, '.codex', 'config.toml'); } } /** Remove the bDS MCP server entry from the agent's config file. */ removeFromConfig(agentId: MCPAgentId): AgentConfigResult { + if (agentId === 'mistral-vibe') { + return this.removeFromVibeConfig(); + } + if (agentId === 'openai-codex') { + return this.removeFromCodexConfig(); + } const configPath = this.getConfigPath(agentId); try { if (!existsSync(configPath)) { return { success: true, configPath }; } - const existing = this.readExisting(configPath); + const existing = this.readExistingJson(configPath); const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers'; const currentServers = (existing[serversKey] as Record | undefined) ?? {}; if (!(SERVER_NAME in currentServers)) { @@ -115,10 +127,16 @@ export class MCPAgentConfigEngine { /** Read-merge-write the bDS MCP server entry into the agent's config file. */ addToConfig(agentId: MCPAgentId): AgentConfigResult { + if (agentId === 'mistral-vibe') { + return this.addToVibeConfig(); + } + if (agentId === 'openai-codex') { + return this.addToCodexConfig(); + } const configPath = this.getConfigPath(agentId); try { - const existing = this.readExisting(configPath); - const merged = this.merge(agentId, existing); + const existing = this.readExistingJson(configPath); + const merged = this.mergeJson(agentId, existing); this.ensureDir(configPath); writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8'); return { success: true, configPath }; @@ -130,6 +148,12 @@ export class MCPAgentConfigEngine { /** Check whether the bDS entry already exists in the agent's config. */ isConfigured(agentId: MCPAgentId): boolean { + if (agentId === 'mistral-vibe') { + return this.isVibeConfigured(); + } + if (agentId === 'openai-codex') { + return this.isCodexConfigured(); + } const configPath = this.getConfigPath(agentId); if (!existsSync(configPath)) return false; try { @@ -141,7 +165,138 @@ export class MCPAgentConfigEngine { } } - // ── Private helpers ────────────────────────────────────────────── + // ── Mistral Vibe (TOML) helpers ────────────────────────────────── + + private addToVibeConfig(): AgentConfigResult { + const configPath = this.getConfigPath('mistral-vibe'); + try { + const existing = this.readExistingToml(configPath); + const servers = (existing.mcp_servers ?? []) as Record[]; + // Remove any existing bDS entry before adding a fresh one. + const filtered = servers.filter((s) => s.name !== SERVER_NAME); + filtered.push({ + name: SERVER_NAME, + transport: 'stdio', + command: this.execPath, + args: [this.scriptPath], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + existing.mcp_servers = filtered; + this.ensureDir(configPath); + writeFileSync(configPath, stringifyToml(existing) + '\n', 'utf-8'); + return { success: true, configPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, configPath, error: message }; + } + } + + private removeFromVibeConfig(): AgentConfigResult { + const configPath = this.getConfigPath('mistral-vibe'); + try { + if (!existsSync(configPath)) { + return { success: true, configPath }; + } + const existing = this.readExistingToml(configPath); + const servers = (existing.mcp_servers ?? []) as Record[]; + const filtered = servers.filter((s) => s.name !== SERVER_NAME); + if (filtered.length === servers.length) { + // Nothing to remove + return { success: true, configPath }; + } + if (filtered.length === 0) { + delete existing.mcp_servers; + } else { + existing.mcp_servers = filtered; + } + this.ensureDir(configPath); + writeFileSync(configPath, stringifyToml(existing) + '\n', 'utf-8'); + return { success: true, configPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, configPath, error: message }; + } + } + + private isVibeConfigured(): boolean { + const configPath = this.getConfigPath('mistral-vibe'); + if (!existsSync(configPath)) return false; + try { + const existing = this.readExistingToml(configPath); + const servers = (existing.mcp_servers ?? []) as Record[]; + return servers.some((s) => s.name === SERVER_NAME); + } catch { + return false; + } + } + + private readExistingToml(configPath: string): Record { + if (!existsSync(configPath)) return {}; + const raw = readFileSync(configPath, 'utf-8'); + return parseToml(raw) as Record; + } + + // ── OpenAI Codex (TOML, table-based) helpers ──────────────────── + + private addToCodexConfig(): AgentConfigResult { + const configPath = this.getConfigPath('openai-codex'); + try { + const existing = this.readExistingToml(configPath); + const servers = (existing.mcp_servers ?? {}) as Record; + servers[SERVER_NAME] = { + command: this.execPath, + args: [this.scriptPath], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }; + existing.mcp_servers = servers; + this.ensureDir(configPath); + writeFileSync(configPath, stringifyToml(existing) + '\n', 'utf-8'); + return { success: true, configPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, configPath, error: message }; + } + } + + private removeFromCodexConfig(): AgentConfigResult { + const configPath = this.getConfigPath('openai-codex'); + try { + if (!existsSync(configPath)) { + return { success: true, configPath }; + } + const existing = this.readExistingToml(configPath); + const servers = (existing.mcp_servers ?? {}) as Record; + if (!(SERVER_NAME in servers)) { + return { success: true, configPath }; + } + delete servers[SERVER_NAME]; + if (Object.keys(servers).length === 0) { + delete existing.mcp_servers; + } else { + existing.mcp_servers = servers; + } + this.ensureDir(configPath); + writeFileSync(configPath, stringifyToml(existing) + '\n', 'utf-8'); + return { success: true, configPath }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, configPath, error: message }; + } + } + + private isCodexConfigured(): boolean { + const configPath = this.getConfigPath('openai-codex'); + if (!existsSync(configPath)) return false; + try { + const existing = this.readExistingToml(configPath); + const servers = (existing.mcp_servers ?? {}) as Record; + return SERVER_NAME in servers; + } catch { + return false; + } + } + + // ── JSON helpers ───────────────────────────────────────────────── private claudeDesktopConfigPath(): string { if (this.platform === 'darwin') { @@ -164,14 +319,14 @@ export class MCPAgentConfigEngine { return path.join(this.homeDir, '.config', 'Code', 'User', 'mcp.json'); } - private readExisting(configPath: string): Record { + private readExistingJson(configPath: string): Record { if (!existsSync(configPath)) return {}; const raw = readFileSync(configPath, 'utf-8'); return JSON.parse(raw) as Record; } - private merge(agentId: MCPAgentId, existing: Record): Record { - const entry = this.buildEntry(agentId); + private mergeJson(agentId: MCPAgentId, existing: Record): Record { + const entry = this.buildJsonEntry(agentId); const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers'; const currentServers = (existing[serversKey] as Record | undefined) ?? {}; @@ -184,28 +339,25 @@ export class MCPAgentConfigEngine { }; } - private buildEntry(agentId: MCPAgentId): Record { + private buildJsonEntry(agentId: MCPAgentId): Record { + const stdioEntry = { + command: this.execPath, + args: [this.scriptPath], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }; + switch (agentId) { case 'claude-code': - return { type: 'http', url: this.mcpUrl }; - case 'claude-desktop': { - if (!this.execPath || !this.scriptPath) { - throw new Error( - 'claude-desktop requires execPath and scriptPath options in MCPAgentConfigOptions', - ); - } - return { - command: this.execPath, - args: [this.scriptPath], - env: { ELECTRON_RUN_AS_NODE: '1' }, - }; - } - case 'github-copilot': - return { type: 'http', url: this.mcpUrl }; + case 'claude-desktop': case 'gemini-cli': - return { httpUrl: this.mcpUrl }; + return stdioEntry; + case 'github-copilot': case 'opencode': - return { type: 'sse', url: this.mcpUrl }; + return { type: 'stdio', ...stdioEntry }; + case 'mistral-vibe': + case 'openai-codex': + // TOML-based; handled separately — should not reach here. + return stdioEntry; } } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 281f263..21f91f0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -122,15 +122,6 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean { } } -function buildMcpUrl(bundle: EngineBundle): string { - try { - const port = bundle.mcpServer.getPort() ?? 4124; - return `http://127.0.0.1:${port}/mcp`; - } catch { - return 'http://127.0.0.1:4124/mcp'; - } -} - function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCPAgentConfigEngine').MCPAgentConfigOptions { const os = require('os') as typeof import('os'); const scriptPath = app.isPackaged @@ -139,7 +130,6 @@ function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCP return { homeDir: os.homedir(), platform: process.platform, - mcpUrl: buildMcpUrl(bundle), execPath: process.execPath, scriptPath, }; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 07d97a2..16b6b06 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -1284,6 +1284,8 @@ export const SettingsView: React.FC = () => { { id: 'github-copilot', label: 'GitHub Copilot' }, { id: 'gemini-cli', label: 'Gemini CLI' }, { id: 'opencode', label: 'OpenCode' }, + { id: 'mistral-vibe', label: 'Mistral Vibe' }, + { id: 'openai-codex', label: 'OpenAI Codex' }, ]; return ( diff --git a/tests/engine/MCPConfigEngine.test.ts b/tests/engine/MCPConfigEngine.test.ts index 6b5fd04..ec8fe96 100644 --- a/tests/engine/MCPConfigEngine.test.ts +++ b/tests/engine/MCPConfigEngine.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { MCPAgentConfigEngine, type MCPAgentId, type AgentConfigResult } from '../../src/main/engine/MCPAgentConfigEngine'; +import { MCPAgentConfigEngine } from '../../src/main/engine/MCPAgentConfigEngine'; +import { stringify as stringifyToml, parse as parseToml } from 'smol-toml'; -// Mock fs and os +// Mock fs const mockReadFileSync = vi.fn(); const mockWriteFileSync = vi.fn(); const mockExistsSync = vi.fn(); @@ -25,6 +26,26 @@ vi.mock('fs', async (importOriginal) => { }; }); +const EXEC = '/path/to/electron'; +const SCRIPT = '/path/to/bds-mcp.cjs'; + +const stdioEntry = { + command: EXEC, + args: [SCRIPT], + env: { ELECTRON_RUN_AS_NODE: '1' }, +}; + +/** Helper to parse the JSON written in the first writeFileSync call. */ +function writtenJson(): Record { + return JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); +} + +/** Helper to parse the TOML written in the first writeFileSync call. */ +function writtenToml(): Record { + const raw = (mockWriteFileSync.mock.calls[0]![1] as string).trim(); + return parseToml(raw) as Record; +} + describe('MCPAgentConfigEngine', () => { let engine: MCPAgentConfigEngine; @@ -33,31 +54,37 @@ describe('MCPAgentConfigEngine', () => { engine = new MCPAgentConfigEngine({ homeDir: '/home/testuser', platform: 'darwin', - mcpUrl: 'http://127.0.0.1:4124/mcp', + execPath: EXEC, + scriptPath: SCRIPT, }); }); + // ── getAgents ──────────────────────────────────────────────────── + describe('getAgents', () => { - it('returns all supported agent definitions', () => { + it('returns all 7 supported agent definitions', () => { const agents = engine.getAgents(); - expect(agents).toHaveLength(5); + expect(agents).toHaveLength(7); const ids = agents.map((a) => a.id); expect(ids).toContain('claude-code'); expect(ids).toContain('claude-desktop'); expect(ids).toContain('github-copilot'); expect(ids).toContain('gemini-cli'); expect(ids).toContain('opencode'); + expect(ids).toContain('mistral-vibe'); + expect(ids).toContain('openai-codex'); }); it('includes display labels for each agent', () => { - const agents = engine.getAgents(); - for (const agent of agents) { + for (const agent of engine.getAgents()) { expect(agent.label).toBeTruthy(); expect(typeof agent.label).toBe('string'); } }); }); + // ── getConfigPath ──────────────────────────────────────────────── + describe('getConfigPath', () => { it('returns ~/.claude.json for claude-code', () => { expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json'); @@ -73,7 +100,8 @@ describe('MCPAgentConfigEngine', () => { const linuxEngine = new MCPAgentConfigEngine({ homeDir: '/home/user', platform: 'linux', - mcpUrl: 'http://127.0.0.1:4124/mcp', + execPath: EXEC, + scriptPath: SCRIPT, }); expect(linuxEngine.getConfigPath('github-copilot')).toBe( '/home/user/.config/Code/User/mcp.json', @@ -87,10 +115,20 @@ describe('MCPAgentConfigEngine', () => { it('returns ~/.opencode.json for opencode', () => { expect(engine.getConfigPath('opencode')).toBe('/home/testuser/.opencode.json'); }); + + it('returns ~/.vibe/config.toml for mistral-vibe', () => { + expect(engine.getConfigPath('mistral-vibe')).toBe('/home/testuser/.vibe/config.toml'); + }); + + it('returns ~/.codex/config.toml for openai-codex', () => { + expect(engine.getConfigPath('openai-codex')).toBe('/home/testuser/.codex/config.toml'); + }); }); + // ── addToConfig (claude-code) ──────────────────────────────────── + describe('addToConfig (claude-code)', () => { - it('creates new config when file does not exist', () => { + it('creates new config with stdio entry when file does not exist', () => { mockExistsSync.mockReturnValue(false); const result = engine.addToConfig('claude-code'); @@ -98,9 +136,7 @@ describe('MCPAgentConfigEngine', () => { expect(result.success).toBe(true); expect(result.configPath).toBe('/home/testuser/.claude.json'); expect(mockWriteFileSync).toHaveBeenCalledOnce(); - - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' }); + expect(writtenJson().mcpServers).toEqual({ bDS: stdioEntry }); }); it('merges into existing config without overwriting other servers', () => { @@ -115,39 +151,40 @@ describe('MCPAgentConfigEngine', () => { const result = engine.addToConfig('claude-code'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.other).toEqual({ type: 'stdio', command: 'npx' }); - expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' }); - expect(written.someOtherKey).toBe('keep'); + const w = writtenJson(); + expect((w.mcpServers as Record)['other']).toEqual({ type: 'stdio', command: 'npx' }); + expect((w.mcpServers as Record)['bDS']).toEqual(stdioEntry); + expect(w.someOtherKey).toBe('keep'); }); - it('overwrites existing bDS entry to update URL', () => { + it('overwrites existing bDS entry to update paths', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ - mcpServers: { bDS: { type: 'http', url: 'http://old:1234/mcp' } }, + mcpServers: { bDS: { command: '/old/exec', args: ['/old/script'] } }, }), ); const result = engine.addToConfig('claude-code'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:4124/mcp'); + const bds = (writtenJson().mcpServers as Record)['bDS'] as Record; + expect(bds.command).toBe(EXEC); }); }); + // ── addToConfig (github-copilot) ───────────────────────────────── + describe('addToConfig (github-copilot)', () => { - it('creates new .vscode/mcp.json with correct servers key', () => { + it('creates new mcp.json with servers key and type: stdio', () => { mockExistsSync.mockReturnValue(false); const result = engine.addToConfig('github-copilot'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.servers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' }); - // Should NOT have mcpServers key - expect(written.mcpServers).toBeUndefined(); + const w = writtenJson(); + expect((w.servers as Record)['bDS']).toEqual({ type: 'stdio', ...stdioEntry }); + expect(w.mcpServers).toBeUndefined(); }); it('creates parent directory if needed', () => { @@ -172,21 +209,22 @@ describe('MCPAgentConfigEngine', () => { const result = engine.addToConfig('github-copilot'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.servers.github.url).toBe('https://api.githubcopilot.com/mcp'); - expect(written.servers.bDS.url).toBe('http://127.0.0.1:4124/mcp'); + const servers = writtenJson().servers as Record>; + expect(servers.github.url).toBe('https://api.githubcopilot.com/mcp'); + expect(servers.bDS).toEqual({ type: 'stdio', ...stdioEntry }); }); }); + // ── addToConfig (gemini-cli) ────────────────────────────────────── + describe('addToConfig (gemini-cli)', () => { - it('creates new settings.json with httpUrl entry', () => { + it('creates new settings.json with stdio entry', () => { mockExistsSync.mockReturnValue(false); const result = engine.addToConfig('gemini-cli'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' }); + expect((writtenJson().mcpServers as Record)['bDS']).toEqual(stdioEntry); }); it('creates ~/.gemini directory if needed', () => { @@ -212,22 +250,27 @@ describe('MCPAgentConfigEngine', () => { const result = engine.addToConfig('gemini-cli'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.theme).toBe('dark'); - expect(written.mcpServers.existing).toEqual({ command: 'python' }); - expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' }); + const w = writtenJson(); + expect(w.theme).toBe('dark'); + const servers = w.mcpServers as Record>; + expect(servers.existing).toEqual({ command: 'python' }); + expect(servers.bDS).toEqual(stdioEntry); }); }); + // ── addToConfig (opencode) ──────────────────────────────────────── + describe('addToConfig (opencode)', () => { - it('creates new .opencode.json with sse type', () => { + it('creates new .opencode.json with type: stdio', () => { mockExistsSync.mockReturnValue(false); const result = engine.addToConfig('opencode'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS).toEqual({ type: 'sse', url: 'http://127.0.0.1:4124/mcp' }); + expect((writtenJson().mcpServers as Record)['bDS']).toEqual({ + type: 'stdio', + ...stdioEntry, + }); }); it('merges into existing opencode config', () => { @@ -242,13 +285,198 @@ describe('MCPAgentConfigEngine', () => { const result = engine.addToConfig('opencode'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.providers.openai.apiKey).toBe('key'); - expect(written.mcpServers.debugger.command).toBe('debug'); - expect(written.mcpServers.bDS.type).toBe('sse'); + const w = writtenJson(); + expect((w.providers as Record>).openai.apiKey).toBe('key'); + const servers = w.mcpServers as Record>; + expect(servers.debugger.command).toBe('debug'); + expect(servers.bDS.type).toBe('stdio'); + expect(servers.bDS.command).toBe(EXEC); }); }); + // ── addToConfig (claude-desktop) ────────────────────────────────── + + describe('addToConfig (claude-desktop)', () => { + it('returns correct config path on macOS', () => { + expect(engine.getConfigPath('claude-desktop')).toBe( + '/home/testuser/Library/Application Support/Claude/claude_desktop_config.json', + ); + }); + + it('returns correct config path on Windows', () => { + const winEngine = new MCPAgentConfigEngine({ + homeDir: 'C:\\Users\\testuser', + platform: 'win32', + execPath: 'C:\\path\\to\\app.exe', + scriptPath: 'C:\\path\\to\\bds-mcp.cjs', + }); + const normalised = winEngine.getConfigPath('claude-desktop').replace(/[\\/]/g, '/'); + expect(normalised).toBe( + 'C:/Users/testuser/AppData/Roaming/Claude/claude_desktop_config.json', + ); + }); + + it('returns correct config path on Linux', () => { + const linuxEngine = new MCPAgentConfigEngine({ + homeDir: '/home/user', + platform: 'linux', + execPath: EXEC, + scriptPath: SCRIPT, + }); + expect(linuxEngine.getConfigPath('claude-desktop')).toBe( + '/home/user/.config/Claude/claude_desktop_config.json', + ); + }); + + it('adds stdio entry with command/args/env', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.addToConfig('claude-desktop'); + + expect(result.success).toBe(true); + expect((writtenJson().mcpServers as Record)['bDS']).toEqual(stdioEntry); + }); + }); + + // ── addToConfig (mistral-vibe) ──────────────────────────────────── + + describe('addToConfig (mistral-vibe)', () => { + it('creates new config.toml with bDS entry', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.addToConfig('mistral-vibe'); + + expect(result.success).toBe(true); + expect(result.configPath).toBe('/home/testuser/.vibe/config.toml'); + const t = writtenToml(); + const servers = t.mcp_servers as Record[]; + expect(servers).toHaveLength(1); + expect(servers[0]).toEqual({ + name: 'bDS', + transport: 'stdio', + command: EXEC, + args: [SCRIPT], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + }); + + it('preserves other servers when adding bDS', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: [ + { name: 'other', transport: 'stdio', command: 'npx', args: ['something'] }, + ], + }), + ); + + const result = engine.addToConfig('mistral-vibe'); + + expect(result.success).toBe(true); + const servers = writtenToml().mcp_servers as Record[]; + expect(servers).toHaveLength(2); + expect(servers.find((s) => s.name === 'other')).toBeDefined(); + expect(servers.find((s) => s.name === 'bDS')).toBeDefined(); + }); + + it('replaces existing bDS entry', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: [ + { name: 'bDS', transport: 'stdio', command: '/old/exec', args: ['/old/script'] }, + ], + }), + ); + + const result = engine.addToConfig('mistral-vibe'); + + expect(result.success).toBe(true); + const servers = writtenToml().mcp_servers as Record[]; + expect(servers).toHaveLength(1); + expect((servers[0] as Record).command).toBe(EXEC); + }); + + it('creates ~/.vibe directory if needed', () => { + mockExistsSync.mockReturnValue(false); + + engine.addToConfig('mistral-vibe'); + + expect(mockMkdirSync).toHaveBeenCalledWith( + expect.stringContaining('.vibe'), + { recursive: true }, + ); + }); + }); + + // ── addToConfig (openai-codex) ──────────────────────────────────── + + describe('addToConfig (openai-codex)', () => { + it('creates new config.toml with bDS entry', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.addToConfig('openai-codex'); + + expect(result.success).toBe(true); + expect(result.configPath).toBe('/home/testuser/.codex/config.toml'); + const t = writtenToml(); + const servers = t.mcp_servers as Record>; + expect(servers.bDS).toEqual({ + command: EXEC, + args: [SCRIPT], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }); + }); + + it('preserves other servers when adding bDS', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: { + other: { command: 'npx', args: ['something'] }, + }, + }), + ); + + const result = engine.addToConfig('openai-codex'); + + expect(result.success).toBe(true); + const servers = writtenToml().mcp_servers as Record>; + expect(servers.other).toBeDefined(); + expect(servers.bDS).toBeDefined(); + }); + + it('replaces existing bDS entry', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: { + bDS: { command: '/old/exec', args: ['/old/script'] }, + }, + }), + ); + + const result = engine.addToConfig('openai-codex'); + + expect(result.success).toBe(true); + const servers = writtenToml().mcp_servers as Record>; + expect(servers.bDS.command).toBe(EXEC); + }); + + it('creates ~/.codex directory if needed', () => { + mockExistsSync.mockReturnValue(false); + + engine.addToConfig('openai-codex'); + + expect(mockMkdirSync).toHaveBeenCalledWith( + expect.stringContaining('.codex'), + { recursive: true }, + ); + }); + }); + + // ── error handling ──────────────────────────────────────────────── + describe('error handling', () => { it('returns error result when read fails', () => { mockExistsSync.mockReturnValue(true); @@ -283,15 +511,25 @@ describe('MCPAgentConfigEngine', () => { expect(result.success).toBe(false); expect(result.error).toBeTruthy(); }); + + it('returns error for invalid existing TOML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('[[[ invalid toml'); + + const result = engine.addToConfig('mistral-vibe'); + + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); }); + // ── isConfigured ────────────────────────────────────────────────── + describe('isConfigured', () => { - it('returns true when bDS entry exists in config', () => { + it('returns true when bDS entry exists in JSON config', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( - JSON.stringify({ - mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } }, - }), + JSON.stringify({ mcpServers: { bDS: { command: EXEC } } }), ); expect(engine.isConfigured('claude-code')).toBe(true); @@ -315,117 +553,88 @@ describe('MCPAgentConfigEngine', () => { it('checks VS Code servers key for github-copilot', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( - JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }), + JSON.stringify({ servers: { bDS: { type: 'stdio', command: EXEC } } }), ); expect(engine.isConfigured('github-copilot')).toBe(true); }); + + it('returns true for mistral-vibe when bDS entry exists in TOML array', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: [{ name: 'bDS', transport: 'stdio', command: EXEC }], + }), + ); + + expect(engine.isConfigured('mistral-vibe')).toBe(true); + }); + + it('returns false for mistral-vibe when bDS is absent', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: [{ name: 'other', transport: 'stdio', command: 'npx' }], + }), + ); + + expect(engine.isConfigured('mistral-vibe')).toBe(false); + }); + + it('returns true for openai-codex when bDS entry exists in TOML table', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: { bDS: { command: EXEC } }, + }), + ); + + expect(engine.isConfigured('openai-codex')).toBe(true); + }); + + it('returns false for openai-codex when bDS is absent', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: { other: { command: 'npx' } }, + }), + ); + + expect(engine.isConfigured('openai-codex')).toBe(false); + }); }); - describe('dynamic port', () => { - it('uses the provided mcpUrl in server entries', () => { + // ── stdio paths ─────────────────────────────────────────────────── + + describe('stdio paths', () => { + it('uses the provided execPath and scriptPath in all entries', () => { const customEngine = new MCPAgentConfigEngine({ homeDir: '/tmp', platform: 'darwin', - mcpUrl: 'http://127.0.0.1:9999/mcp', + execPath: '/custom/electron', + scriptPath: '/custom/bds-mcp.cjs', }); mockExistsSync.mockReturnValue(false); customEngine.addToConfig('claude-code'); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp'); + const bds = (writtenJson().mcpServers as Record>).bDS; + expect(bds.command).toBe('/custom/electron'); + expect(bds.args).toEqual(['/custom/bds-mcp.cjs']); + expect(bds.env).toEqual({ ELECTRON_RUN_AS_NODE: '1' }); }); }); - describe('claude-desktop', () => { - let desktopEngine: MCPAgentConfigEngine; - - beforeEach(() => { - desktopEngine = new MCPAgentConfigEngine({ - homeDir: '/home/testuser', - platform: 'darwin', - mcpUrl: 'http://127.0.0.1:4124/mcp', - execPath: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server', - scriptPath: '/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs', - }); - }); - - it('includes claude-desktop in getAgents()', () => { - const agents = desktopEngine.getAgents(); - expect(agents.map((a) => a.id)).toContain('claude-desktop'); - }); - - it('returns correct config path for claude-desktop on macOS', () => { - expect(desktopEngine.getConfigPath('claude-desktop')).toBe( - '/home/testuser/Library/Application Support/Claude/claude_desktop_config.json', - ); - }); - - it('returns correct config path for claude-desktop on Windows', () => { - const winEngine = new MCPAgentConfigEngine({ - homeDir: 'C:\\Users\\testuser', - platform: 'win32', - mcpUrl: 'http://127.0.0.1:4124/mcp', - execPath: 'C:\\path\\to\\app.exe', - scriptPath: 'C:\\path\\to\\bds-mcp.cjs', - }); - const configPath = winEngine.getConfigPath('claude-desktop'); - // On Windows path.join uses backslashes; on macOS it uses forward slashes - // so normalise for cross-platform CI - const normalised = configPath.replace(/[\\/]/g, '/'); - expect(normalised).toBe( - 'C:/Users/testuser/AppData/Roaming/Claude/claude_desktop_config.json', - ); - }); - - it('returns correct config path for claude-desktop on Linux', () => { - const linuxEngine = new MCPAgentConfigEngine({ - homeDir: '/home/user', - platform: 'linux', - mcpUrl: 'http://127.0.0.1:4124/mcp', - }); - expect(linuxEngine.getConfigPath('claude-desktop')).toBe( - '/home/user/.config/Claude/claude_desktop_config.json', - ); - }); - - it('adds stdio entry with command/args/env to claude_desktop_config.json', () => { - mockExistsSync.mockReturnValue(false); - - const result = desktopEngine.addToConfig('claude-desktop'); - - expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS).toEqual({ - command: '/Applications/Blogging Desktop Server.app/Contents/MacOS/Blogging Desktop Server', - args: ['/Applications/Blogging Desktop Server.app/Contents/Resources/bds-mcp.cjs'], - env: { ELECTRON_RUN_AS_NODE: '1' }, - }); - }); - - it('throws descriptive error if execPath/scriptPath missing for claude-desktop', () => { - const noPathEngine = new MCPAgentConfigEngine({ - homeDir: '/home/testuser', - platform: 'darwin', - mcpUrl: 'http://127.0.0.1:4124/mcp', - }); - mockExistsSync.mockReturnValue(false); - - const result = noPathEngine.addToConfig('claude-desktop'); - expect(result.success).toBe(false); - expect(result.error).toContain('execPath'); - }); - }); + // ── removeFromConfig ────────────────────────────────────────────── describe('removeFromConfig', () => { - it('removes bDS entry from config and returns success', () => { + it('removes bDS entry from JSON config and returns success', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ mcpServers: { - bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' }, - other: { type: 'http', url: 'http://other' }, + bDS: stdioEntry, + other: { command: 'npx' }, }, }), ); @@ -433,23 +642,20 @@ describe('MCPAgentConfigEngine', () => { const result = engine.removeFromConfig('claude-code'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers.bDS).toBeUndefined(); - expect(written.mcpServers.other).toBeDefined(); + const w = writtenJson(); + expect((w.mcpServers as Record)['bDS']).toBeUndefined(); + expect((w.mcpServers as Record)['other']).toBeDefined(); }); it('removes the mcpServers key entirely when bDS was the only entry', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( - JSON.stringify({ - mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } }, - }), + JSON.stringify({ mcpServers: { bDS: stdioEntry } }), ); engine.removeFromConfig('claude-code'); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.mcpServers).toBeUndefined(); + expect(writtenJson().mcpServers).toBeUndefined(); }); it('no-ops gracefully when file does not exist', () => { @@ -474,14 +680,13 @@ describe('MCPAgentConfigEngine', () => { it('uses the servers key for github-copilot', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( - JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }), + JSON.stringify({ servers: { bDS: { type: 'stdio', ...stdioEntry } } }), ); const result = engine.removeFromConfig('github-copilot'); expect(result.success).toBe(true); - const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); - expect(written.servers).toBeUndefined(); + expect(writtenJson().servers).toBeUndefined(); }); it('returns success with configPath', () => { @@ -492,5 +697,87 @@ describe('MCPAgentConfigEngine', () => { expect(result.success).toBe(true); expect(result.configPath).toBe('/home/testuser/.claude.json'); }); + + it('removes bDS from mistral-vibe TOML array', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: [ + { name: 'bDS', transport: 'stdio', command: EXEC, args: [SCRIPT] }, + { name: 'other', transport: 'stdio', command: 'npx', args: ['x'] }, + ], + }), + ); + + const result = engine.removeFromConfig('mistral-vibe'); + + expect(result.success).toBe(true); + const servers = writtenToml().mcp_servers as Record[]; + expect(servers).toHaveLength(1); + expect((servers[0] as Record).name).toBe('other'); + }); + + it('removes mcp_servers key from vibe when bDS was the only entry', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: [{ name: 'bDS', transport: 'stdio', command: EXEC, args: [SCRIPT] }], + }), + ); + + engine.removeFromConfig('mistral-vibe'); + + expect(writtenToml().mcp_servers).toBeUndefined(); + }); + + it('no-ops when mistral-vibe config does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.removeFromConfig('mistral-vibe'); + + expect(result.success).toBe(true); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('removes bDS from openai-codex TOML table', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: { + bDS: { command: EXEC, args: [SCRIPT] }, + other: { command: 'npx', args: ['x'] }, + }, + }), + ); + + const result = engine.removeFromConfig('openai-codex'); + + expect(result.success).toBe(true); + const servers = writtenToml().mcp_servers as Record>; + expect(servers.bDS).toBeUndefined(); + expect(servers.other).toBeDefined(); + }); + + it('removes mcp_servers key from codex when bDS was the only entry', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + stringifyToml({ + mcp_servers: { bDS: { command: EXEC, args: [SCRIPT] } }, + }), + ); + + engine.removeFromConfig('openai-codex'); + + expect(writtenToml().mcp_servers).toBeUndefined(); + }); + + it('no-ops when openai-codex config does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = engine.removeFromConfig('openai-codex'); + + expect(result.success).toBe(true); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); }); });