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

View File

@@ -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<string, unknown> | 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<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 {
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<string, unknown> {
private readExistingJson(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) return {};
const raw = readFileSync(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
}
private merge(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> {
const entry = this.buildEntry(agentId);
private mergeJson(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> {
const entry = this.buildJsonEntry(agentId);
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
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) {
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;
}
}

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 {
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,
};