diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index a920ba34..faaecc9c 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -192,7 +192,7 @@ "category": "Settings", "description": "Add claude and codex to the left sidebar of settings so its will scroll to thoes sections as well", "steps": [], - "status": "waiting_approval", + "status": "verified", "startedAt": "2025-12-10T09:32:31.638Z", "imagePaths": [ { diff --git a/app/electron/main.js b/app/electron/main.js index 805fd5de..2b9826bd 100644 --- a/app/electron/main.js +++ b/app/electron/main.js @@ -734,6 +734,43 @@ ipcMain.handle("model:check-providers", async () => { } }); +// ============================================================================ +// MCP Server IPC Handlers +// ============================================================================ + +/** + * Handle MCP server callback for updating feature status + * This can be called by the MCP server script via HTTP or other communication mechanism + * Note: The MCP server script runs as a separate process, so it can't directly use Electron IPC. + * For now, the MCP server calls featureLoader.updateFeatureStatus directly. + * This handler is here for future extensibility (e.g., HTTP endpoint bridge). + */ +ipcMain.handle("mcp:update-feature-status", async (_, { featureId, status, projectPath, summary }) => { + try { + const featureLoader = require("./services/feature-loader"); + await featureLoader.updateFeatureStatus(featureId, status, projectPath, summary); + + // Notify renderer if window is available + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("mcp:feature-status-updated", { + featureId, + status, + projectPath, + summary + }); + } + + return { success: true }; + } catch (error) { + console.error("[IPC] mcp:update-feature-status error:", error); + return { success: false, error: error.message }; + } +}); + +// ============================================================================ +// OpenAI API Handlers +// ============================================================================ + /** * Test OpenAI API connection */ diff --git a/app/electron/services/codex-config-manager.js b/app/electron/services/codex-config-manager.js new file mode 100644 index 00000000..37832f61 --- /dev/null +++ b/app/electron/services/codex-config-manager.js @@ -0,0 +1,351 @@ +/** + * Codex TOML Configuration Manager + * + * Manages Codex CLI's TOML configuration file to add/update MCP server settings. + * Codex CLI looks for config at: + * - ~/.codex/config.toml (user-level) + * - .codex/config.toml (project-level, takes precedence) + */ + +const fs = require('fs/promises'); +const path = require('path'); +const os = require('os'); + +class CodexConfigManager { + constructor() { + this.userConfigPath = path.join(os.homedir(), '.codex', 'config.toml'); + this.projectConfigPath = null; // Will be set per project + } + + /** + * Set the project path for project-level config + */ + setProjectPath(projectPath) { + this.projectConfigPath = path.join(projectPath, '.codex', 'config.toml'); + } + + /** + * Get the effective config path (project-level if exists, otherwise user-level) + */ + async getConfigPath() { + if (this.projectConfigPath) { + try { + await fs.access(this.projectConfigPath); + return this.projectConfigPath; + } catch (e) { + // Project config doesn't exist, fall back to user config + } + } + + // Ensure user config directory exists + const userConfigDir = path.dirname(this.userConfigPath); + try { + await fs.mkdir(userConfigDir, { recursive: true }); + } catch (e) { + // Directory might already exist + } + + return this.userConfigPath; + } + + /** + * Read existing TOML config (simple parser for our needs) + */ + async readConfig(configPath) { + try { + const content = await fs.readFile(configPath, 'utf-8'); + return this.parseToml(content); + } catch (e) { + if (e.code === 'ENOENT') { + return {}; + } + throw e; + } + } + + /** + * Simple TOML parser for our specific use case + * This is a minimal parser that handles the MCP server config structure + */ + parseToml(content) { + const config = {}; + let currentSection = null; + let currentSubsection = null; + + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + // Section header: [section] + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const sectionName = sectionMatch[1]; + const parts = sectionName.split('.'); + + if (parts.length === 1) { + currentSection = parts[0]; + currentSubsection = null; + if (!config[currentSection]) { + config[currentSection] = {}; + } + } else if (parts.length === 2) { + currentSection = parts[0]; + currentSubsection = parts[1]; + if (!config[currentSection]) { + config[currentSection] = {}; + } + if (!config[currentSection][currentSubsection]) { + config[currentSection][currentSubsection] = {}; + } + } + continue; + } + + // Key-value pair: key = value + const kvMatch = trimmed.match(/^([^=]+)=(.+)$/); + if (kvMatch) { + const key = kvMatch[1].trim(); + let value = kvMatch[2].trim(); + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + // Parse boolean + if (value === 'true') value = true; + else if (value === 'false') value = false; + // Parse number + else if (/^-?\d+$/.test(value)) value = parseInt(value, 10); + else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value); + + if (currentSubsection) { + if (!config[currentSection][currentSubsection]) { + config[currentSection][currentSubsection] = {}; + } + config[currentSection][currentSubsection][key] = value; + } else if (currentSection) { + if (!config[currentSection]) { + config[currentSection] = {}; + } + config[currentSection][key] = value; + } else { + config[key] = value; + } + } + } + + return config; + } + + /** + * Convert config object back to TOML format + */ + stringifyToml(config, indent = 0) { + const indentStr = ' '.repeat(indent); + let result = ''; + + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Section + result += `${indentStr}[${key}]\n`; + result += this.stringifyToml(value, indent); + } else { + // Key-value + let valueStr = value; + if (typeof value === 'string') { + // Escape quotes and wrap in quotes if needed + if (value.includes('"') || value.includes("'") || value.includes(' ')) { + valueStr = `"${value.replace(/"/g, '\\"')}"`; + } + } else if (typeof value === 'boolean') { + valueStr = value.toString(); + } + result += `${indentStr}${key} = ${valueStr}\n`; + } + } + + return result; + } + + /** + * Configure the automaker-tools MCP server + */ + async configureMcpServer(projectPath, mcpServerScriptPath) { + this.setProjectPath(projectPath); + const configPath = await this.getConfigPath(); + + // Read existing config + const config = await this.readConfig(configPath); + + // Ensure mcp_servers section exists + if (!config.mcp_servers) { + config.mcp_servers = {}; + } + + // Configure automaker-tools server + config.mcp_servers['automaker-tools'] = { + command: 'node', + args: [mcpServerScriptPath], + env: { + AUTOMAKER_PROJECT_PATH: projectPath + }, + startup_timeout_sec: 10, + tool_timeout_sec: 60, + enabled_tools: ['UpdateFeatureStatus'] + }; + + // Ensure experimental_use_rmcp_client is enabled (if needed) + if (!config.experimental_use_rmcp_client) { + config.experimental_use_rmcp_client = true; + } + + // Write config back + await this.writeConfig(configPath, config); + + console.log(`[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}`); + return configPath; + } + + /** + * Write config to TOML file + */ + async writeConfig(configPath, config) { + let content = ''; + + // Write top-level keys first (preserve existing non-MCP config) + for (const [key, value] of Object.entries(config)) { + if (key === 'mcp_servers' || key === 'experimental_use_rmcp_client') { + continue; // Handle these separately + } + if (typeof value !== 'object') { + content += `${key} = ${this.formatValue(value)}\n`; + } + } + + // Write experimental flag if enabled + if (config.experimental_use_rmcp_client) { + if (content && !content.endsWith('\n\n')) { + content += '\n'; + } + content += `experimental_use_rmcp_client = true\n`; + } + + // Write mcp_servers section + if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) { + if (content && !content.endsWith('\n\n')) { + content += '\n'; + } + + for (const [serverName, serverConfig] of Object.entries(config.mcp_servers)) { + content += `\n[mcp_servers.${serverName}]\n`; + + // Write command first + if (serverConfig.command) { + content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`; + } + + // Write args + if (serverConfig.args && Array.isArray(serverConfig.args)) { + const argsStr = serverConfig.args.map(a => `"${this.escapeTomlString(a)}"`).join(', '); + content += `args = [${argsStr}]\n`; + } + + // Write timeouts (must be before env subsection) + if (serverConfig.startup_timeout_sec !== undefined) { + content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`; + } + + if (serverConfig.tool_timeout_sec !== undefined) { + content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`; + } + + // Write enabled_tools (must be before env subsection - at server level, not env level) + if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) { + const toolsStr = serverConfig.enabled_tools.map(t => `"${this.escapeTomlString(t)}"`).join(', '); + content += `enabled_tools = [${toolsStr}]\n`; + } + + // Write env section last (as a separate subsection) + // IMPORTANT: In TOML, once we start [mcp_servers.server_name.env], + // everything after belongs to that subsection until a new section starts + if (serverConfig.env && typeof serverConfig.env === 'object' && Object.keys(serverConfig.env).length > 0) { + content += `\n[mcp_servers.${serverName}.env]\n`; + for (const [envKey, envValue] of Object.entries(serverConfig.env)) { + content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`; + } + } + } + } + + // Ensure directory exists + const configDir = path.dirname(configPath); + await fs.mkdir(configDir, { recursive: true }); + + // Write file + await fs.writeFile(configPath, content, 'utf-8'); + } + + /** + * Escape special characters in TOML strings + */ + escapeTomlString(str) { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + } + + /** + * Format a value for TOML output + */ + formatValue(value) { + if (typeof value === 'string') { + // Escape quotes + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; + } else if (typeof value === 'boolean') { + return value.toString(); + } else if (typeof value === 'number') { + return value.toString(); + } + return `"${String(value)}"`; + } + + /** + * Remove automaker-tools MCP server configuration + */ + async removeMcpServer(projectPath) { + this.setProjectPath(projectPath); + const configPath = await this.getConfigPath(); + + try { + const config = await this.readConfig(configPath); + + if (config.mcp_servers && config.mcp_servers['automaker-tools']) { + delete config.mcp_servers['automaker-tools']; + + // If no more MCP servers, remove the section + if (Object.keys(config.mcp_servers).length === 0) { + delete config.mcp_servers; + } + + await this.writeConfig(configPath, config); + console.log(`[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}`); + } + } catch (e) { + console.error(`[CodexConfigManager] Error removing MCP server config:`, e); + } + } +} + +module.exports = new CodexConfigManager(); diff --git a/app/electron/services/codex-executor.js b/app/electron/services/codex-executor.js index eae4fe4a..b051c170 100644 --- a/app/electron/services/codex-executor.js +++ b/app/electron/services/codex-executor.js @@ -8,7 +8,9 @@ const { spawn } = require('child_process'); const { EventEmitter } = require('events'); const readline = require('readline'); +const path = require('path'); const CodexCliDetector = require('./codex-cli-detector'); +const codexConfigManager = require('./codex-config-manager'); /** * Message types from Codex CLI JSON output @@ -59,6 +61,7 @@ class CodexExecutor extends EventEmitter { * @param {number} options.maxTurns Not used - Codex CLI doesn't support this parameter * @param {string[]} options.allowedTools Not used - Codex CLI doesn't support this parameter * @param {Object} options.env Environment variables + * @param {Object} options.mcpServers MCP servers configuration (for configuring Codex TOML) * @returns {AsyncGenerator} Generator yielding messages */ async *execute(options) { @@ -69,7 +72,8 @@ class CodexExecutor extends EventEmitter { systemPrompt, maxTurns, // Not used by Codex CLI allowedTools, // Not used by Codex CLI - env = {} + env = {}, + mcpServers = null } = options; const codexPath = this.findCodexPath(); @@ -81,6 +85,27 @@ class CodexExecutor extends EventEmitter { return; } + // Configure MCP server if provided + if (mcpServers && mcpServers['automaker-tools']) { + try { + // Get the absolute path to the MCP server script + const mcpServerScriptPath = path.resolve(__dirname, 'mcp-server-stdio.js'); + + // Verify the script exists + const fs = require('fs'); + if (!fs.existsSync(mcpServerScriptPath)) { + console.warn(`[CodexExecutor] MCP server script not found at ${mcpServerScriptPath}, skipping MCP configuration`); + } else { + // Configure Codex TOML to use the MCP server + await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath); + console.log('[CodexExecutor] Configured automaker-tools MCP server for Codex CLI'); + } + } catch (error) { + console.error('[CodexExecutor] Failed to configure MCP server:', error); + // Continue execution even if MCP config fails - Codex will work without MCP tools + } + } + // Combine system prompt with main prompt if provided // Codex CLI doesn't support --system-prompt argument, so we prepend it to the prompt let combinedPrompt = prompt; diff --git a/app/electron/services/feature-executor.js b/app/electron/services/feature-executor.js index bac9a4f1..155d9caa 100644 --- a/app/electron/services/feature-executor.js +++ b/app/electron/services/feature-executor.js @@ -397,6 +397,7 @@ class FeatureExecutor { // Use Codex provider for OpenAI models console.log(`[FeatureExecutor] Using Codex provider for model: ${modelString}`); + // Pass MCP server config to Codex provider so it can configure Codex CLI TOML currentQuery = provider.executeQuery({ prompt, model: modelString, @@ -404,6 +405,9 @@ class FeatureExecutor { systemPrompt: promptBuilder.getCodingPrompt(), maxTurns: 20, // Codex CLI typically uses fewer turns allowedTools: options.allowedTools, + mcpServers: { + "automaker-tools": featureToolsServer + }, abortController: abortController, env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY diff --git a/app/electron/services/mcp-server-stdio.js b/app/electron/services/mcp-server-stdio.js new file mode 100644 index 00000000..b7f5c1db --- /dev/null +++ b/app/electron/services/mcp-server-stdio.js @@ -0,0 +1,347 @@ +#!/usr/bin/env node +/** + * Standalone STDIO MCP Server for Automaker Tools + * + * This script runs as a standalone process and communicates via JSON-RPC 2.0 + * over stdin/stdout. It implements the MCP protocol to expose the UpdateFeatureStatus + * tool to Codex CLI. + * + * Environment variables: + * - AUTOMAKER_PROJECT_PATH: Path to the project directory + * - AUTOMAKER_IPC_CHANNEL: IPC channel name for callback communication (optional, uses default) + */ + +const readline = require('readline'); +const path = require('path'); + +// Redirect all console.log output to stderr to avoid polluting MCP stdout +const originalConsoleLog = console.log; +console.log = (...args) => { + console.error(...args); +}; + +// Set up readline interface for line-by-line JSON-RPC input +// IMPORTANT: Use a separate output stream for readline to avoid interfering with JSON-RPC stdout +// We'll write JSON-RPC responses directly to stdout, not through readline +const rl = readline.createInterface({ + input: process.stdin, + output: null, // Don't use stdout for readline output + terminal: false +}); + +let initialized = false; +let projectPath = null; +let ipcChannel = null; + +// Get configuration from environment +projectPath = process.env.AUTOMAKER_PROJECT_PATH || process.cwd(); +ipcChannel = process.env.AUTOMAKER_IPC_CHANNEL || 'mcp:update-feature-status'; + +// Load dependencies (these will be available in the Electron app context) +let featureLoader; +let electron; + +// Try to load Electron IPC if available (when running from Electron app) +try { + // In Electron, we can use IPC directly + if (typeof require !== 'undefined') { + // Check if we're in Electron context + const electronModule = require('electron'); + if (electronModule && electronModule.ipcMain) { + electron = electronModule; + } + } +} catch (e) { + // Not in Electron context, will use alternative method +} + +// Load feature loader +// Try multiple paths since this script might be run from different contexts +try { + // First try relative path (when run from electron/services/) + featureLoader = require('./feature-loader'); +} catch (e) { + try { + // Try absolute path resolution + const featureLoaderPath = path.resolve(__dirname, 'feature-loader.js'); + delete require.cache[require.resolve(featureLoaderPath)]; + featureLoader = require(featureLoaderPath); + } catch (e2) { + // If still fails, try from parent directory + try { + featureLoader = require(path.join(__dirname, '..', 'services', 'feature-loader')); + } catch (e3) { + console.error('[McpServerStdio] Error loading feature-loader:', e3.message); + console.error('[McpServerStdio] Tried paths:', [ + './feature-loader', + path.resolve(__dirname, 'feature-loader.js'), + path.join(__dirname, '..', 'services', 'feature-loader') + ]); + process.exit(1); + } + } +} + +/** + * Send JSON-RPC response + * CRITICAL: Must write directly to stdout, not via console.log + * MCP protocol requires ONLY JSON-RPC messages on stdout + */ +function sendResponse(id, result, error = null) { + const response = { + jsonrpc: '2.0', + id + }; + + if (error) { + response.error = error; + } else { + response.result = result; + } + + // Write directly to stdout with newline (MCP uses line-delimited JSON) + process.stdout.write(JSON.stringify(response) + '\n'); +} + +/** + * Send JSON-RPC notification + * CRITICAL: Must write directly to stdout, not via console.log + */ +function sendNotification(method, params) { + const notification = { + jsonrpc: '2.0', + method, + params + }; + + // Write directly to stdout with newline (MCP uses line-delimited JSON) + process.stdout.write(JSON.stringify(notification) + '\n'); +} + +/** + * Handle MCP initialize request + */ +async function handleInitialize(params, id) { + initialized = true; + + sendResponse(id, { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'automaker-tools', + version: '1.0.0' + } + }); +} + +/** + * Handle tools/list request + */ +async function handleToolsList(params, id) { + sendResponse(id, { + tools: [ + { + name: 'UpdateFeatureStatus', + description: 'Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.', + inputSchema: { + type: 'object', + properties: { + featureId: { + type: 'string', + description: 'The ID of the feature to update' + }, + status: { + type: 'string', + enum: ['backlog', 'in_progress', 'verified'], + description: 'The new status for the feature. Note: If skipTests=true, verified will be converted to waiting_approval automatically.' + }, + summary: { + type: 'string', + description: 'A brief summary of what was implemented/changed. This will be displayed on the Kanban card. Example: "Added dark mode toggle. Modified: settings.tsx, theme-provider.tsx"' + } + }, + required: ['featureId', 'status'] + } + } + ] + }); +} + +/** + * Handle tools/call request + */ +async function handleToolsCall(params, id) { + const { name, arguments: args } = params; + + if (name !== 'UpdateFeatureStatus') { + sendResponse(id, null, { + code: -32601, + message: `Unknown tool: ${name}` + }); + return; + } + + try { + const { featureId, status, summary } = args; + + if (!featureId || !status) { + sendResponse(id, null, { + code: -32602, + message: 'Missing required parameters: featureId and status are required' + }); + return; + } + + // Load the feature to check skipTests flag + const features = await featureLoader.loadFeatures(projectPath); + const feature = features.find((f) => f.id === featureId); + + if (!feature) { + sendResponse(id, null, { + code: -32602, + message: `Feature ${featureId} not found` + }); + return; + } + + // If agent tries to mark as verified but feature has skipTests=true, convert to waiting_approval + let finalStatus = status; + if (status === 'verified' && feature.skipTests === true) { + finalStatus = 'waiting_approval'; + } + + // Call the update callback via IPC or direct call + // Since we're in a separate process, we need to use IPC to communicate back + // For now, we'll call the feature loader directly since it has the update method + await featureLoader.updateFeatureStatus(featureId, finalStatus, projectPath, summary); + + const statusMessage = finalStatus !== status + ? `Successfully updated feature ${featureId} to status "${finalStatus}" (converted from "${status}" because skipTests=true)${summary ? ` with summary: "${summary}"` : ''}` + : `Successfully updated feature ${featureId} to status "${finalStatus}"${summary ? ` with summary: "${summary}"` : ''}`; + + sendResponse(id, { + content: [ + { + type: 'text', + text: statusMessage + } + ] + }); + } catch (error) { + console.error('[McpServerStdio] UpdateFeatureStatus error:', error); + sendResponse(id, null, { + code: -32603, + message: `Failed to update feature status: ${error.message}` + }); + } +} + +/** + * Handle JSON-RPC request + */ +async function handleRequest(line) { + let request; + + try { + request = JSON.parse(line); + } catch (e) { + sendResponse(null, null, { + code: -32700, + message: 'Parse error' + }); + return; + } + + // Validate JSON-RPC 2.0 structure + if (request.jsonrpc !== '2.0') { + sendResponse(request.id || null, null, { + code: -32600, + message: 'Invalid Request' + }); + return; + } + + const { method, params, id } = request; + + // Handle notifications (no id) + if (id === undefined) { + // Handle notifications if needed + return; + } + + // Handle requests + try { + switch (method) { + case 'initialize': + await handleInitialize(params, id); + break; + + case 'tools/list': + if (!initialized) { + sendResponse(id, null, { + code: -32002, + message: 'Server not initialized' + }); + return; + } + await handleToolsList(params, id); + break; + + case 'tools/call': + if (!initialized) { + sendResponse(id, null, { + code: -32002, + message: 'Server not initialized' + }); + return; + } + await handleToolsCall(params, id); + break; + + default: + sendResponse(id, null, { + code: -32601, + message: `Method not found: ${method}` + }); + } + } catch (error) { + console.error('[McpServerStdio] Error handling request:', error); + sendResponse(id, null, { + code: -32603, + message: `Internal error: ${error.message}` + }); + } +} + +// Process stdin line by line +rl.on('line', async (line) => { + if (!line.trim()) { + return; + } + + await handleRequest(line); +}); + +// Handle errors +rl.on('error', (error) => { + console.error('[McpServerStdio] Readline error:', error); + process.exit(1); +}); + +// Handle process termination +process.on('SIGTERM', () => { + rl.close(); + process.exit(0); +}); + +process.on('SIGINT', () => { + rl.close(); + process.exit(0); +}); + +// Log startup +console.error('[McpServerStdio] Starting MCP server for automaker-tools'); +console.error(`[McpServerStdio] Project path: ${projectPath}`); +console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`); diff --git a/app/electron/services/model-provider.js b/app/electron/services/model-provider.js index d009089e..d5a31850 100644 --- a/app/electron/services/model-provider.js +++ b/app/electron/services/model-provider.js @@ -297,6 +297,7 @@ class CodexProvider extends ModelProvider { systemPrompt: options.systemPrompt, maxTurns: options.maxTurns || 20, allowedTools: options.allowedTools, + mcpServers: options.mcpServers, // Pass MCP servers config to executor env: { ...process.env, OPENAI_API_KEY: process.env.OPENAI_API_KEY