aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
384 lines (334 loc) • 12.3 kB
JavaScript
/**
* AIOS-FullStack MCP Installation Module
* Story 1.5: MCP Installation (Project-Level)
*
* Installs 4 essential MCPs at project level:
* - Browser (Puppeteer)
* - Context7
* - Exa
* - Desktop Commander
*
* @module mcp-installer
* @version 1.0.0
*/
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const chalk = require('chalk');
const execAsync = promisify(exec);
/**
* MCP Configuration Templates
*/
const MCP_CONFIGS = {
browser: {
id: 'browser',
name: 'Browser (Puppeteer)',
package: '@modelcontextprotocol/server-puppeteer',
transport: 'stdio',
healthCheck: {
type: 'navigation',
timeout: 30000,
description: 'Navigate to blank page test',
},
getConfig: (platform) => {
if (platform === 'win32') {
return {
command: 'cmd',
args: ['/c', 'npx', '-y', '@modelcontextprotocol/server-puppeteer'],
};
}
return {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-puppeteer'],
};
},
},
context7: {
id: 'context7',
name: 'Context7',
package: '@upstash/context7-mcp',
transport: 'stdio',
healthCheck: {
type: 'config-validation',
timeout: 5000,
description: 'Configuration validation',
},
getConfig: (platform) => {
// Use npx/stdio transport for reliable local execution
// HTTP endpoint returns 406 for non-MCP protocol requests
if (platform === 'win32') {
return {
command: 'cmd',
args: ['/c', 'npx', '-y', '@upstash/context7-mcp'],
};
}
return {
command: 'npx',
args: ['-y', '@upstash/context7-mcp'],
};
},
},
exa: {
id: 'exa',
name: 'Exa Web Search',
package: 'exa-mcp-server',
transport: 'stdio',
requiresApiKey: true,
apiKeyEnvVar: 'EXA_API_KEY',
healthCheck: {
type: 'api-test',
timeout: 10000,
description: 'Web search test',
},
getConfig: (platform, apiKey) => {
const tools = '--tools=web_search_exa,research_paper_search,company_research,crawling,competitor_finder,linkedin_search,wikipedia_search_exa,github_search';
if (platform === 'win32') {
return {
command: 'cmd',
args: ['/c', 'npx', '-y', 'exa-mcp-server', tools],
env: {
EXA_API_KEY: apiKey || '${EXA_API_KEY}',
},
};
}
return {
command: 'npx',
args: ['-y', 'exa-mcp-server', tools],
env: {
EXA_API_KEY: apiKey || '${EXA_API_KEY}',
},
};
},
},
'desktop-commander': {
id: 'desktop-commander',
name: 'Desktop Commander',
package: '@wonderwhy-er/desktop-commander',
transport: 'stdio',
healthCheck: {
type: 'file-access',
timeout: 5000,
description: 'File system access test',
},
getConfig: (platform) => {
if (platform === 'win32') {
return {
command: 'cmd',
args: ['/c', 'npx', '-y', '@wonderwhy-er/desktop-commander'],
};
}
return {
command: 'npx',
args: ['-y', '@wonderwhy-er/desktop-commander'],
};
},
},
};
/**
* Install project-level MCPs
*
* @param {Object} options - Installation options
* @param {string[]} options.selectedMCPs - Array of MCP IDs to install
* @param {string} options.projectPath - Project root path
* @param {Object} options.apiKeys - API keys (e.g., { EXA_API_KEY: 'key' })
* @param {Function} options.onProgress - Progress callback
* @returns {Promise<Object>} Installation result
*/
async function installProjectMCPs(options = {}) {
const {
selectedMCPs = ['browser', 'context7', 'exa', 'desktop-commander'],
projectPath = process.cwd(),
apiKeys = {},
onProgress = () => {},
} = options;
const platform = process.platform;
const results = {
success: false,
installedMCPs: {},
configPath: path.join(projectPath, '.mcp.json'),
errors: [],
};
// Create .aios directory for logs
const aiosDir = path.join(projectPath, '.aios');
await fse.ensureDir(aiosDir);
const logPath = path.join(aiosDir, 'install-log.txt');
const errorLogPath = path.join(aiosDir, 'install-errors.log');
// Initialize logs
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Starting MCP installation...`);
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Platform: ${platform}`);
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Selected MCPs: ${selectedMCPs.join(', ')}`);
// Backup existing .mcp.json if exists
const mcpConfigPath = path.join(projectPath, '.mcp.json');
if (fs.existsSync(mcpConfigPath)) {
const backupPath = path.join(projectPath, '.mcp.json.backup');
await fse.copy(mcpConfigPath, backupPath);
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Backed up existing .mcp.json`);
}
try {
// Install MCPs in parallel for performance
onProgress({ phase: 'installation', message: 'Installing MCPs...' });
const installPromises = selectedMCPs.map(async (mcpId) => {
const mcpConfig = MCP_CONFIGS[mcpId];
if (!mcpConfig) {
const errorMsg = `Unknown MCP: ${mcpId}`;
await appendLog(logPath, `[${new Date().toISOString()}] [ERROR] ${errorMsg}`);
await appendLog(errorLogPath, `[${new Date().toISOString()}] [${mcpId}] ${errorMsg}`);
return { mcpId, status: 'failed', message: errorMsg };
}
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Installing ${mcpConfig.name}...`);
onProgress({ phase: 'installation', mcp: mcpId, message: `Installing ${mcpConfig.name}...` });
try {
// Install npm package if needed
if (mcpConfig.package) {
await installNpmPackage(mcpConfig.package, projectPath, logPath);
}
// Generate config
const config = typeof mcpConfig.getConfig === 'function'
? mcpConfig.getConfig(platform, apiKeys[mcpConfig.apiKeyEnvVar])
: mcpConfig.getConfig;
// Add to .mcp.json
await addMCPToConfig(mcpId, config, mcpConfigPath);
await appendLog(logPath, `[${new Date().toISOString()}] [SUCCESS] ${mcpConfig.name} installed successfully`);
return { mcpId, status: 'success', message: 'Installed successfully' };
} catch (error) {
await appendLog(logPath, `[${new Date().toISOString()}] [ERROR] ${mcpConfig.name} installation failed: ${error.message}`);
await appendLog(errorLogPath, `[${new Date().toISOString()}] [${mcpId}] ${error.stack}`);
return { mcpId, status: 'failed', message: error.message };
}
});
const installResults = await Promise.allSettled(installPromises);
// Process install results
for (const result of installResults) {
if (result.status === 'fulfilled') {
const { mcpId, status, message } = result.value;
results.installedMCPs[mcpId] = { status, message };
// Track failed installations in errors array
if (status === 'failed') {
results.errors.push(`${mcpId}: ${message}`);
}
} else {
// Promise rejected - this should not happen with current code structure
// but handle it defensively
const errorMsg = result.reason?.message || 'Unknown error';
results.errors.push(`Promise rejection: ${errorMsg}`);
await appendLog(errorLogPath, `[${new Date().toISOString()}] [CRITICAL] Unexpected promise rejection: ${result.reason?.stack || errorMsg}`);
}
}
// NOTE: Health checks deferred to Story 1.8 (Installation Validation)
// MCP functionality will be validated when servers are first started
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Health checks deferred to Story 1.8`);
// Determine overall success
const failedCount = Object.values(results.installedMCPs).filter(r => r.status === 'failed').length;
results.success = failedCount === 0;
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] MCP installation complete`);
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Success: ${Object.values(results.installedMCPs).filter(r => r.status === 'success').length}`);
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Warnings: ${Object.values(results.installedMCPs).filter(r => r.status === 'warning').length}`);
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Failed: ${failedCount}`);
return results;
} catch (error) {
await appendLog(logPath, `[${new Date().toISOString()}] [ERROR] Installation failed: ${error.message}`);
await appendLog(errorLogPath, `[${new Date().toISOString()}] ${error.stack}`);
results.success = false;
results.errors.push(error.message);
return results;
}
}
/**
* Validate npm package availability via npx
* @private
*
* Note: This is optional validation. Packages that don't support --version
* will be installed automatically by npx when the MCP server first starts.
* This validation helps catch obvious package name typos during installation.
*/
async function installNpmPackage(packageName, projectPath, logPath) {
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Validating package: ${packageName}`);
try {
// Test if npx can find the package
// Note: This may fail if package doesn't support --version flag
await execAsync(`npx -y ${packageName} --version`, {
cwd: projectPath,
timeout: 10000,
});
await appendLog(logPath, `[${new Date().toISOString()}] [SUCCESS] Package ${packageName} validated`);
} catch (error) {
// Package validation failed - could be:
// 1. Package doesn't support --version (non-critical)
// 2. Package name is invalid (will fail when MCP starts)
// 3. Network timeout (may succeed later)
await appendLog(logPath, `[${new Date().toISOString()}] [INFO] Package ${packageName} validation skipped (${error.message}) - will be installed on first use`);
// Not throwing - npx will install/validate when MCP server actually starts
}
}
/**
* Add MCP configuration to .mcp.json
* @private
*/
async function addMCPToConfig(mcpId, config, configPath) {
let mcpConfig = { mcpServers: {} };
// Load existing config if exists
if (fs.existsSync(configPath)) {
try {
const content = await fse.readFile(configPath, 'utf8');
mcpConfig = JSON.parse(content);
// Ensure mcpServers object exists
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
throw new Error(`Failed to parse existing .mcp.json: ${error.message}`);
}
}
// Add new MCP config
mcpConfig.mcpServers[mcpId] = config;
// Write updated config
await fse.writeFile(
configPath,
JSON.stringify(mcpConfig, null, 2) + '\n',
'utf8',
);
}
/**
* Append to log file
* @private
*/
async function appendLog(logPath, message) {
try {
await fse.appendFile(logPath, message + '\n', 'utf8');
} catch {
// Silently fail log writes - not critical to installation
}
}
/**
* Display installation status
*/
function displayInstallationStatus(results) {
console.log('');
console.log(chalk.cyan('📊 MCP Installation Status:'));
console.log('');
for (const [mcpId, result] of Object.entries(results.installedMCPs)) {
const mcpConfig = MCP_CONFIGS[mcpId];
const icon = result.status === 'success' ? chalk.green('✓')
: result.status === 'warning' ? chalk.yellow('⚠️')
: chalk.red('❌');
console.log(` ${icon} ${mcpConfig.name}: ${result.message}`);
}
console.log('');
if (results.errors.length > 0) {
console.log(chalk.yellow('⚠️ Errors encountered:'));
results.errors.forEach(error => {
console.log(chalk.yellow(` - ${error}`));
});
console.log('');
}
console.log(chalk.gray(`📁 Configuration: ${results.configPath}`));
console.log(chalk.gray('📋 Installation log: .aios/install-log.txt'));
console.log('');
}
module.exports = {
installProjectMCPs,
displayInstallationStatus,
MCP_CONFIGS,
};