feat: agents use stdio now and vibe added

This commit is contained in:
2026-02-28 23:02:58 +01:00
parent 97f51d565d
commit 46cdadbaca
6 changed files with 639 additions and 193 deletions

14
package-lock.json generated
View File

@@ -48,6 +48,7 @@
"rsyncwrapper": "^3.1.0", "rsyncwrapper": "^3.1.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"simple-git": "^3.31.1", "simple-git": "^3.31.1",
"smol-toml": "^1.6.0",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
"turndown": "^7.2.2", "turndown": "^7.2.2",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -4617,6 +4618,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.9",
"ajv": "^8.17.1", "ajv": "^8.17.1",
@@ -14628,6 +14630,18 @@
"npm": ">= 3.0.0" "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": { "node_modules/snowball-stemmers": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/snowball-stemmers/-/snowball-stemmers-0.6.0.tgz", "resolved": "https://registry.npmjs.org/snowball-stemmers/-/snowball-stemmers-0.6.0.tgz",

View File

@@ -109,6 +109,7 @@
"rsyncwrapper": "^3.1.0", "rsyncwrapper": "^3.1.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"simple-git": "^3.31.1", "simple-git": "^3.31.1",
"smol-toml": "^1.6.0",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
"turndown": "^7.2.2", "turndown": "^7.2.2",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View File

@@ -1,17 +1,20 @@
/** /**
* MCPAgentConfigEngine adds the bDS MCP server entry to coding-agent config files. * MCPAgentConfigEngine adds the bDS MCP server entry to coding-agent config files.
* *
* Supports: Claude Code, GitHub Copilot (VS Code), Gemini CLI, OpenCode. * Supports: Claude Code, Claude Desktop, GitHub Copilot (VS Code), Gemini CLI,
* Each agent has its own config file format; this engine reads, merges, and writes * OpenCode, Mistral Vibe, OpenAI Codex.
* the appropriate JSON structure without overwriting existing entries. *
* 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 { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import path from 'path'; import path from 'path';
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
// ── Public types ───────────────────────────────────────────────────── // ── 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 { export interface AgentDefinition {
id: MCPAgentId; id: MCPAgentId;
@@ -27,11 +30,10 @@ export interface AgentConfigResult {
export interface MCPAgentConfigOptions { export interface MCPAgentConfigOptions {
homeDir: string; homeDir: string;
platform: NodeJS.Platform; platform: NodeJS.Platform;
mcpUrl: string; /** Absolute path to the Electron executable (used as the stdio command). */
/** Required when agentId is 'claude-desktop'; unused otherwise. */ execPath: string;
execPath?: string; /** Absolute path to the bds-mcp.cjs script. */
/** Required when agentId is 'claude-desktop'; unused otherwise. */ scriptPath: string;
scriptPath?: string;
} }
// ── Agent definitions ──────────────────────────────────────────────── // ── Agent definitions ────────────────────────────────────────────────
@@ -42,6 +44,8 @@ const AGENTS: AgentDefinition[] = [
{ id: 'github-copilot', label: 'GitHub Copilot' }, { id: 'github-copilot', label: 'GitHub Copilot' },
{ id: 'gemini-cli', label: 'Gemini CLI' }, { id: 'gemini-cli', label: 'Gemini CLI' },
{ id: 'opencode', label: 'OpenCode' }, { id: 'opencode', label: 'OpenCode' },
{ id: 'mistral-vibe', label: 'Mistral Vibe' },
{ id: 'openai-codex', label: 'OpenAI Codex' },
]; ];
const SERVER_NAME = 'bDS'; const SERVER_NAME = 'bDS';
@@ -51,14 +55,12 @@ const SERVER_NAME = 'bDS';
export class MCPAgentConfigEngine { export class MCPAgentConfigEngine {
private readonly homeDir: string; private readonly homeDir: string;
private readonly platform: NodeJS.Platform; private readonly platform: NodeJS.Platform;
private readonly mcpUrl: string; private readonly execPath: string;
private readonly execPath?: string; private readonly scriptPath: string;
private readonly scriptPath?: string;
constructor(opts: MCPAgentConfigOptions) { constructor(opts: MCPAgentConfigOptions) {
this.homeDir = opts.homeDir; this.homeDir = opts.homeDir;
this.platform = opts.platform; this.platform = opts.platform;
this.mcpUrl = opts.mcpUrl;
this.execPath = opts.execPath; this.execPath = opts.execPath;
this.scriptPath = opts.scriptPath; this.scriptPath = opts.scriptPath;
} }
@@ -81,17 +83,27 @@ export class MCPAgentConfigEngine {
return path.join(this.homeDir, '.gemini', 'settings.json'); return path.join(this.homeDir, '.gemini', 'settings.json');
case 'opencode': case 'opencode':
return path.join(this.homeDir, '.opencode.json'); 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. */ /** Remove the bDS MCP server entry from the agent's config file. */
removeFromConfig(agentId: MCPAgentId): AgentConfigResult { removeFromConfig(agentId: MCPAgentId): AgentConfigResult {
if (agentId === 'mistral-vibe') {
return this.removeFromVibeConfig();
}
if (agentId === 'openai-codex') {
return this.removeFromCodexConfig();
}
const configPath = this.getConfigPath(agentId); const configPath = this.getConfigPath(agentId);
try { try {
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
return { success: true, configPath }; return { success: true, configPath };
} }
const existing = this.readExisting(configPath); const existing = this.readExistingJson(configPath);
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers'; const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {}; const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {};
if (!(SERVER_NAME in currentServers)) { 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. */ /** Read-merge-write the bDS MCP server entry into the agent's config file. */
addToConfig(agentId: MCPAgentId): AgentConfigResult { addToConfig(agentId: MCPAgentId): AgentConfigResult {
if (agentId === 'mistral-vibe') {
return this.addToVibeConfig();
}
if (agentId === 'openai-codex') {
return this.addToCodexConfig();
}
const configPath = this.getConfigPath(agentId); const configPath = this.getConfigPath(agentId);
try { try {
const existing = this.readExisting(configPath); const existing = this.readExistingJson(configPath);
const merged = this.merge(agentId, existing); const merged = this.mergeJson(agentId, existing);
this.ensureDir(configPath); this.ensureDir(configPath);
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8'); writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
return { success: true, configPath }; return { success: true, configPath };
@@ -130,6 +148,12 @@ export class MCPAgentConfigEngine {
/** Check whether the bDS entry already exists in the agent's config. */ /** Check whether the bDS entry already exists in the agent's config. */
isConfigured(agentId: MCPAgentId): boolean { isConfigured(agentId: MCPAgentId): boolean {
if (agentId === 'mistral-vibe') {
return this.isVibeConfigured();
}
if (agentId === 'openai-codex') {
return this.isCodexConfigured();
}
const configPath = this.getConfigPath(agentId); const configPath = this.getConfigPath(agentId);
if (!existsSync(configPath)) return false; if (!existsSync(configPath)) return false;
try { 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<string, unknown>[];
// 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<string, unknown>[];
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<string, unknown>[];
return servers.some((s) => s.name === SERVER_NAME);
} catch {
return false;
}
}
private readExistingToml(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) return {};
const raw = readFileSync(configPath, 'utf-8');
return parseToml(raw) as Record<string, unknown>;
}
// ── 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
return SERVER_NAME in servers;
} catch {
return false;
}
}
// ── JSON helpers ─────────────────────────────────────────────────
private claudeDesktopConfigPath(): string { private claudeDesktopConfigPath(): string {
if (this.platform === 'darwin') { if (this.platform === 'darwin') {
@@ -164,14 +319,14 @@ export class MCPAgentConfigEngine {
return path.join(this.homeDir, '.config', 'Code', 'User', 'mcp.json'); return path.join(this.homeDir, '.config', 'Code', 'User', 'mcp.json');
} }
private readExisting(configPath: string): Record<string, unknown> { private readExistingJson(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) return {}; if (!existsSync(configPath)) return {};
const raw = readFileSync(configPath, 'utf-8'); const raw = readFileSync(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>; return JSON.parse(raw) as Record<string, unknown>;
} }
private merge(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> { private mergeJson(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> {
const entry = this.buildEntry(agentId); const entry = this.buildJsonEntry(agentId);
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers'; const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {}; const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {};
@@ -184,28 +339,25 @@ export class MCPAgentConfigEngine {
}; };
} }
private buildEntry(agentId: MCPAgentId): Record<string, unknown> { private buildJsonEntry(agentId: MCPAgentId): Record<string, unknown> {
const stdioEntry = {
command: this.execPath,
args: [this.scriptPath],
env: { ELECTRON_RUN_AS_NODE: '1' },
};
switch (agentId) { switch (agentId) {
case 'claude-code': case 'claude-code':
return { type: 'http', url: this.mcpUrl }; case 'claude-desktop':
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 'gemini-cli': case 'gemini-cli':
return { httpUrl: this.mcpUrl }; return stdioEntry;
case 'github-copilot':
case 'opencode': 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;
} }
} }

View File

@@ -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 { function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCPAgentConfigEngine').MCPAgentConfigOptions {
const os = require('os') as typeof import('os'); const os = require('os') as typeof import('os');
const scriptPath = app.isPackaged const scriptPath = app.isPackaged
@@ -139,7 +130,6 @@ function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCP
return { return {
homeDir: os.homedir(), homeDir: os.homedir(),
platform: process.platform, platform: process.platform,
mcpUrl: buildMcpUrl(bundle),
execPath: process.execPath, execPath: process.execPath,
scriptPath, scriptPath,
}; };

View File

@@ -1284,6 +1284,8 @@ export const SettingsView: React.FC = () => {
{ id: 'github-copilot', label: 'GitHub Copilot' }, { id: 'github-copilot', label: 'GitHub Copilot' },
{ id: 'gemini-cli', label: 'Gemini CLI' }, { id: 'gemini-cli', label: 'Gemini CLI' },
{ id: 'opencode', label: 'OpenCode' }, { id: 'opencode', label: 'OpenCode' },
{ id: 'mistral-vibe', label: 'Mistral Vibe' },
{ id: 'openai-codex', label: 'OpenAI Codex' },
]; ];
return ( return (

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; 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 mockReadFileSync = vi.fn();
const mockWriteFileSync = vi.fn(); const mockWriteFileSync = vi.fn();
const mockExistsSync = 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<string, unknown> {
return JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
}
/** Helper to parse the TOML written in the first writeFileSync call. */
function writtenToml(): Record<string, unknown> {
const raw = (mockWriteFileSync.mock.calls[0]![1] as string).trim();
return parseToml(raw) as Record<string, unknown>;
}
describe('MCPAgentConfigEngine', () => { describe('MCPAgentConfigEngine', () => {
let engine: MCPAgentConfigEngine; let engine: MCPAgentConfigEngine;
@@ -33,31 +54,37 @@ describe('MCPAgentConfigEngine', () => {
engine = new MCPAgentConfigEngine({ engine = new MCPAgentConfigEngine({
homeDir: '/home/testuser', homeDir: '/home/testuser',
platform: 'darwin', platform: 'darwin',
mcpUrl: 'http://127.0.0.1:4124/mcp', execPath: EXEC,
scriptPath: SCRIPT,
}); });
}); });
// ── getAgents ────────────────────────────────────────────────────
describe('getAgents', () => { describe('getAgents', () => {
it('returns all supported agent definitions', () => { it('returns all 7 supported agent definitions', () => {
const agents = engine.getAgents(); const agents = engine.getAgents();
expect(agents).toHaveLength(5); expect(agents).toHaveLength(7);
const ids = agents.map((a) => a.id); const ids = agents.map((a) => a.id);
expect(ids).toContain('claude-code'); expect(ids).toContain('claude-code');
expect(ids).toContain('claude-desktop'); expect(ids).toContain('claude-desktop');
expect(ids).toContain('github-copilot'); expect(ids).toContain('github-copilot');
expect(ids).toContain('gemini-cli'); expect(ids).toContain('gemini-cli');
expect(ids).toContain('opencode'); expect(ids).toContain('opencode');
expect(ids).toContain('mistral-vibe');
expect(ids).toContain('openai-codex');
}); });
it('includes display labels for each agent', () => { it('includes display labels for each agent', () => {
const agents = engine.getAgents(); for (const agent of engine.getAgents()) {
for (const agent of agents) {
expect(agent.label).toBeTruthy(); expect(agent.label).toBeTruthy();
expect(typeof agent.label).toBe('string'); expect(typeof agent.label).toBe('string');
} }
}); });
}); });
// ── getConfigPath ────────────────────────────────────────────────
describe('getConfigPath', () => { describe('getConfigPath', () => {
it('returns ~/.claude.json for claude-code', () => { it('returns ~/.claude.json for claude-code', () => {
expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json'); expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json');
@@ -73,7 +100,8 @@ describe('MCPAgentConfigEngine', () => {
const linuxEngine = new MCPAgentConfigEngine({ const linuxEngine = new MCPAgentConfigEngine({
homeDir: '/home/user', homeDir: '/home/user',
platform: 'linux', platform: 'linux',
mcpUrl: 'http://127.0.0.1:4124/mcp', execPath: EXEC,
scriptPath: SCRIPT,
}); });
expect(linuxEngine.getConfigPath('github-copilot')).toBe( expect(linuxEngine.getConfigPath('github-copilot')).toBe(
'/home/user/.config/Code/User/mcp.json', '/home/user/.config/Code/User/mcp.json',
@@ -87,10 +115,20 @@ describe('MCPAgentConfigEngine', () => {
it('returns ~/.opencode.json for opencode', () => { it('returns ~/.opencode.json for opencode', () => {
expect(engine.getConfigPath('opencode')).toBe('/home/testuser/.opencode.json'); 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)', () => { 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); mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('claude-code'); const result = engine.addToConfig('claude-code');
@@ -98,9 +136,7 @@ describe('MCPAgentConfigEngine', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.configPath).toBe('/home/testuser/.claude.json'); expect(result.configPath).toBe('/home/testuser/.claude.json');
expect(mockWriteFileSync).toHaveBeenCalledOnce(); expect(mockWriteFileSync).toHaveBeenCalledOnce();
expect(writtenJson().mcpServers).toEqual({ bDS: stdioEntry });
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' });
}); });
it('merges into existing config without overwriting other servers', () => { it('merges into existing config without overwriting other servers', () => {
@@ -115,39 +151,40 @@ describe('MCPAgentConfigEngine', () => {
const result = engine.addToConfig('claude-code'); const result = engine.addToConfig('claude-code');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const w = writtenJson();
expect(written.mcpServers.other).toEqual({ type: 'stdio', command: 'npx' }); expect((w.mcpServers as Record<string, unknown>)['other']).toEqual({ type: 'stdio', command: 'npx' });
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' }); expect((w.mcpServers as Record<string, unknown>)['bDS']).toEqual(stdioEntry);
expect(written.someOtherKey).toBe('keep'); expect(w.someOtherKey).toBe('keep');
}); });
it('overwrites existing bDS entry to update URL', () => { it('overwrites existing bDS entry to update paths', () => {
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue( mockReadFileSync.mockReturnValue(
JSON.stringify({ 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'); const result = engine.addToConfig('claude-code');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const bds = (writtenJson().mcpServers as Record<string, unknown>)['bDS'] as Record<string, unknown>;
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:4124/mcp'); expect(bds.command).toBe(EXEC);
}); });
}); });
// ── addToConfig (github-copilot) ─────────────────────────────────
describe('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); mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('github-copilot'); const result = engine.addToConfig('github-copilot');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const w = writtenJson();
expect(written.servers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' }); expect((w.servers as Record<string, unknown>)['bDS']).toEqual({ type: 'stdio', ...stdioEntry });
// Should NOT have mcpServers key expect(w.mcpServers).toBeUndefined();
expect(written.mcpServers).toBeUndefined();
}); });
it('creates parent directory if needed', () => { it('creates parent directory if needed', () => {
@@ -172,21 +209,22 @@ describe('MCPAgentConfigEngine', () => {
const result = engine.addToConfig('github-copilot'); const result = engine.addToConfig('github-copilot');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const servers = writtenJson().servers as Record<string, Record<string, unknown>>;
expect(written.servers.github.url).toBe('https://api.githubcopilot.com/mcp'); expect(servers.github.url).toBe('https://api.githubcopilot.com/mcp');
expect(written.servers.bDS.url).toBe('http://127.0.0.1:4124/mcp'); expect(servers.bDS).toEqual({ type: 'stdio', ...stdioEntry });
}); });
}); });
// ── addToConfig (gemini-cli) ──────────────────────────────────────
describe('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); mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('gemini-cli'); const result = engine.addToConfig('gemini-cli');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); expect((writtenJson().mcpServers as Record<string, unknown>)['bDS']).toEqual(stdioEntry);
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
}); });
it('creates ~/.gemini directory if needed', () => { it('creates ~/.gemini directory if needed', () => {
@@ -212,22 +250,27 @@ describe('MCPAgentConfigEngine', () => {
const result = engine.addToConfig('gemini-cli'); const result = engine.addToConfig('gemini-cli');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const w = writtenJson();
expect(written.theme).toBe('dark'); expect(w.theme).toBe('dark');
expect(written.mcpServers.existing).toEqual({ command: 'python' }); const servers = w.mcpServers as Record<string, Record<string, unknown>>;
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' }); expect(servers.existing).toEqual({ command: 'python' });
expect(servers.bDS).toEqual(stdioEntry);
}); });
}); });
// ── addToConfig (opencode) ────────────────────────────────────────
describe('addToConfig (opencode)', () => { describe('addToConfig (opencode)', () => {
it('creates new .opencode.json with sse type', () => { it('creates new .opencode.json with type: stdio', () => {
mockExistsSync.mockReturnValue(false); mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('opencode'); const result = engine.addToConfig('opencode');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); expect((writtenJson().mcpServers as Record<string, unknown>)['bDS']).toEqual({
expect(written.mcpServers.bDS).toEqual({ type: 'sse', url: 'http://127.0.0.1:4124/mcp' }); type: 'stdio',
...stdioEntry,
});
}); });
it('merges into existing opencode config', () => { it('merges into existing opencode config', () => {
@@ -242,13 +285,198 @@ describe('MCPAgentConfigEngine', () => {
const result = engine.addToConfig('opencode'); const result = engine.addToConfig('opencode');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const w = writtenJson();
expect(written.providers.openai.apiKey).toBe('key'); expect((w.providers as Record<string, Record<string, string>>).openai.apiKey).toBe('key');
expect(written.mcpServers.debugger.command).toBe('debug'); const servers = w.mcpServers as Record<string, Record<string, unknown>>;
expect(written.mcpServers.bDS.type).toBe('sse'); 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<string, unknown>)['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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
expect(servers).toHaveLength(1);
expect((servers[0] as Record<string, unknown>).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<string, Record<string, unknown>>;
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<string, Record<string, unknown>>;
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<string, Record<string, unknown>>;
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', () => { describe('error handling', () => {
it('returns error result when read fails', () => { it('returns error result when read fails', () => {
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
@@ -283,15 +511,25 @@ describe('MCPAgentConfigEngine', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBeTruthy(); 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', () => { describe('isConfigured', () => {
it('returns true when bDS entry exists in config', () => { it('returns true when bDS entry exists in JSON config', () => {
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue( mockReadFileSync.mockReturnValue(
JSON.stringify({ JSON.stringify({ mcpServers: { bDS: { command: EXEC } } }),
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
}),
); );
expect(engine.isConfigured('claude-code')).toBe(true); expect(engine.isConfigured('claude-code')).toBe(true);
@@ -315,117 +553,88 @@ describe('MCPAgentConfigEngine', () => {
it('checks VS Code servers key for github-copilot', () => { it('checks VS Code servers key for github-copilot', () => {
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue( 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); 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', () => { // ── stdio paths ───────────────────────────────────────────────────
it('uses the provided mcpUrl in server entries', () => {
describe('stdio paths', () => {
it('uses the provided execPath and scriptPath in all entries', () => {
const customEngine = new MCPAgentConfigEngine({ const customEngine = new MCPAgentConfigEngine({
homeDir: '/tmp', homeDir: '/tmp',
platform: 'darwin', platform: 'darwin',
mcpUrl: 'http://127.0.0.1:9999/mcp', execPath: '/custom/electron',
scriptPath: '/custom/bds-mcp.cjs',
}); });
mockExistsSync.mockReturnValue(false); mockExistsSync.mockReturnValue(false);
customEngine.addToConfig('claude-code'); customEngine.addToConfig('claude-code');
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const bds = (writtenJson().mcpServers as Record<string, Record<string, unknown>>).bDS;
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp'); 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', () => { // ── removeFromConfig ──────────────────────────────────────────────
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');
});
});
describe('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); mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue( mockReadFileSync.mockReturnValue(
JSON.stringify({ JSON.stringify({
mcpServers: { mcpServers: {
bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' }, bDS: stdioEntry,
other: { type: 'http', url: 'http://other' }, other: { command: 'npx' },
}, },
}), }),
); );
@@ -433,23 +642,20 @@ describe('MCPAgentConfigEngine', () => {
const result = engine.removeFromConfig('claude-code'); const result = engine.removeFromConfig('claude-code');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); const w = writtenJson();
expect(written.mcpServers.bDS).toBeUndefined(); expect((w.mcpServers as Record<string, unknown>)['bDS']).toBeUndefined();
expect(written.mcpServers.other).toBeDefined(); expect((w.mcpServers as Record<string, unknown>)['other']).toBeDefined();
}); });
it('removes the mcpServers key entirely when bDS was the only entry', () => { it('removes the mcpServers key entirely when bDS was the only entry', () => {
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue( mockReadFileSync.mockReturnValue(
JSON.stringify({ JSON.stringify({ mcpServers: { bDS: stdioEntry } }),
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
}),
); );
engine.removeFromConfig('claude-code'); engine.removeFromConfig('claude-code');
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); expect(writtenJson().mcpServers).toBeUndefined();
expect(written.mcpServers).toBeUndefined();
}); });
it('no-ops gracefully when file does not exist', () => { it('no-ops gracefully when file does not exist', () => {
@@ -474,14 +680,13 @@ describe('MCPAgentConfigEngine', () => {
it('uses the servers key for github-copilot', () => { it('uses the servers key for github-copilot', () => {
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue( mockReadFileSync.mockReturnValue(
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }), JSON.stringify({ servers: { bDS: { type: 'stdio', ...stdioEntry } } }),
); );
const result = engine.removeFromConfig('github-copilot'); const result = engine.removeFromConfig('github-copilot');
expect(result.success).toBe(true); expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string); expect(writtenJson().servers).toBeUndefined();
expect(written.servers).toBeUndefined();
}); });
it('returns success with configPath', () => { it('returns success with configPath', () => {
@@ -492,5 +697,87 @@ describe('MCPAgentConfigEngine', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.configPath).toBe('/home/testuser/.claude.json'); 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<string, unknown>[];
expect(servers).toHaveLength(1);
expect((servers[0] as Record<string, unknown>).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<string, Record<string, unknown>>;
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();
});
}); });
}); });