feat(mcp): implement MCP server integration for Codex CLI

- Added a new CodexConfigManager to manage TOML configuration for MCP server settings.
- Introduced MCP server IPC handlers in main.js to facilitate feature status updates.
- Enhanced CodexExecutor and FeatureExecutor to configure and utilize MCP server settings.
- Created a standalone MCP server script for JSON-RPC communication with Codex CLI.
- Updated model-provider to pass MCP server configuration to the executor.

These changes enable seamless integration of the MCP server with Codex CLI, improving feature management and execution capabilities.
This commit is contained in:
Kacper
2025-12-10 10:56:47 +01:00
parent 379e5fc727
commit 18604078a6
7 changed files with 767 additions and 2 deletions

View File

@@ -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": [
{

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -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;

View File

@@ -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

View File

@@ -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}`);

View File

@@ -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