#!/usr/bin/env node 'use strict'; /** * Merge ECC-recommended MCP servers into a Codex config.toml. * * Strategy: ADD-ONLY by default. * - Parse the TOML to detect which mcp_servers.* sections exist. * - Append raw TOML text for any missing servers (preserves existing file byte-for-byte). * - Log warnings when an existing server's config differs from the ECC recommendation. * - With --update-mcp, also replace existing ECC-managed servers. * * Uses the repo's package-manager abstraction (scripts/lib/package-manager.js) * so MCP launcher commands respect the user's configured package manager. * * Usage: * node merge-mcp-config.js [--dry-run] [--update-mcp] */ const fs = require('fs'); const path = require('path'); let TOML; try { TOML = require('@iarna/toml'); } catch { console.error('[ecc-mcp] Missing dependency: @iarna/toml'); console.error('[ecc-mcp] Run: npm install (from the ECC repo root)'); process.exit(1); } // --------------------------------------------------------------------------- // Package manager detection // --------------------------------------------------------------------------- let pmConfig; try { const { getPackageManager } = require(path.join(__dirname, '..', 'lib', 'package-manager.js')); pmConfig = getPackageManager(); } catch { // Fallback: if package-manager.js isn't available, default to npx pmConfig = { name: 'npm', config: { name: 'npm', execCmd: 'npx' } }; } // Yarn 1.x doesn't support `yarn dlx` — fall back to npx for classic Yarn. let resolvedExecCmd = pmConfig.config.execCmd; if (pmConfig.name === 'yarn' && resolvedExecCmd === 'yarn dlx') { try { const { execFileSync } = require('child_process'); const ver = execFileSync('yarn', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim(); if (ver.startsWith('1.')) { resolvedExecCmd = 'npx'; } } catch { // Can't detect version — keep yarn dlx and let it fail visibly } } const PM_NAME = pmConfig.config.name || pmConfig.name; const PM_EXEC = resolvedExecCmd; // e.g. "pnpm dlx", "npx", "bunx", "yarn dlx" const PM_EXEC_PARTS = PM_EXEC.split(/\s+/); // ["pnpm", "dlx"] or ["npx"] or ["bunx"] // --------------------------------------------------------------------------- // ECC-recommended MCP servers // --------------------------------------------------------------------------- // GitHub bootstrap uses bash for token forwarding — this is intentionally // shell-based regardless of package manager, since Codex runs on macOS/Linux. const GH_BOOTSTRAP = `token=$(gh auth token 2>/dev/null || true); if [ -n "$token" ]; then export GITHUB_PERSONAL_ACCESS_TOKEN="$token"; fi; exec ${PM_EXEC} @modelcontextprotocol/server-github`; /** * Build a server spec with the detected package manager. * Returns { fields, toml } where fields is for drift detection and * toml is the raw text appended to the file. */ function dlxServer(name, pkg, extraFields, extraToml) { const args = [...PM_EXEC_PARTS.slice(1), pkg]; const fields = { command: PM_EXEC_PARTS[0], args, ...extraFields }; const argsStr = JSON.stringify(args).replace(/,/g, ', '); let toml = `[mcp_servers.${name}]\ncommand = "${PM_EXEC_PARTS[0]}"\nargs = ${argsStr}`; if (extraToml) toml += '\n' + extraToml; return { fields, toml }; } /** Each entry: key = section name under mcp_servers, value = { toml, fields } */ const ECC_SERVERS = { supabase: dlxServer('supabase', '@supabase/mcp-server-supabase@latest', { startup_timeout_sec: 20.0, tool_timeout_sec: 120.0 }, 'startup_timeout_sec = 20.0\ntool_timeout_sec = 120.0'), playwright: dlxServer('playwright', '@playwright/mcp@latest'), 'context7-mcp': dlxServer('context7-mcp', '@upstash/context7-mcp'), exa: { fields: { url: 'https://mcp.exa.ai/mcp' }, toml: `[mcp_servers.exa]\nurl = "https://mcp.exa.ai/mcp"` }, github: { fields: { command: 'bash', args: ['-lc', GH_BOOTSTRAP] }, toml: `[mcp_servers.github]\ncommand = "bash"\nargs = ["-lc", ${JSON.stringify(GH_BOOTSTRAP)}]` }, memory: dlxServer('memory', '@modelcontextprotocol/server-memory'), 'sequential-thinking': dlxServer('sequential-thinking', '@modelcontextprotocol/server-sequential-thinking') }; // Append --features arg for supabase after dlxServer builds the base ECC_SERVERS.supabase.fields.args.push('--features=account,docs,database,debugging,development,functions,storage,branching'); ECC_SERVERS.supabase.toml = ECC_SERVERS.supabase.toml.replace(/^(args = \[.*)\]$/m, '$1, "--features=account,docs,database,debugging,development,functions,storage,branching"]'); // Legacy section names that should be treated as an existing ECC server. // e.g. old configs shipped [mcp_servers.context7] instead of [mcp_servers.context7-mcp]. const LEGACY_ALIASES = { 'context7-mcp': ['context7'] }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function log(msg) { console.log(`[ecc-mcp] ${msg}`); } function warn(msg) { console.warn(`[ecc-mcp] WARNING: ${msg}`); } /** Shallow-compare two objects (one level deep, arrays by JSON). */ function configDiffers(existing, recommended) { for (const key of Object.keys(recommended)) { const a = existing[key]; const b = recommended[key]; if (Array.isArray(b)) { if (JSON.stringify(a) !== JSON.stringify(b)) return true; } else if (a !== b) { return true; } } return false; } /** * Remove a TOML section and its key-value pairs from raw text. * Matches the section header even if followed by inline comments or whitespace * (e.g. `[mcp_servers.github] # comment`). * Returns the text with the section removed. */ function removeSectionFromText(text, sectionHeader) { const escaped = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const headerPattern = new RegExp(`^${escaped}(\\s*(#.*)?)?$`); const lines = text.split('\n'); const result = []; let skipping = false; for (const line of lines) { const trimmed = line.replace(/\r$/, ''); if (headerPattern.test(trimmed)) { skipping = true; continue; } if (skipping && /^\[/.test(trimmed)) { skipping = false; } if (!skipping) { result.push(line); } } return result.join('\n'); } /** * Collect all TOML sub-section headers for a given server name. * @iarna/toml nests subtables, so `[mcp_servers.supabase.env]` appears as * `parsed.mcp_servers.supabase.env` (nested), NOT as a flat dotted key. * Walk the nested object to find sub-objects that represent TOML sub-tables. */ function findSubSections(serverObj, prefix) { const sections = []; if (!serverObj || typeof serverObj !== 'object') return sections; for (const key of Object.keys(serverObj)) { const val = serverObj[key]; if (val && typeof val === 'object' && !Array.isArray(val)) { const subPath = `${prefix}.${key}`; sections.push(subPath); sections.push(...findSubSections(val, subPath)); } } return sections; } /** * Remove a server and all its sub-sections from raw TOML text. * Uses findSubSections to walk the parsed nested object (not flat keys). */ function removeServerFromText(raw, serverName, existing) { let result = removeSectionFromText(raw, `[mcp_servers.${serverName}]`); const serverObj = existing[serverName]; if (serverObj) { for (const sub of findSubSections(serverObj, serverName)) { result = removeSectionFromText(result, `[mcp_servers.${sub}]`); } } return result; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { const args = process.argv.slice(2); const configPath = args.find(a => !a.startsWith('-')); const dryRun = args.includes('--dry-run'); const updateMcp = args.includes('--update-mcp'); if (!configPath) { console.error('Usage: merge-mcp-config.js [--dry-run] [--update-mcp]'); process.exit(1); } if (!fs.existsSync(configPath)) { console.error(`[ecc-mcp] Config file not found: ${configPath}`); process.exit(1); } log(`Package manager: ${PM_NAME} (exec: ${PM_EXEC})`); let raw = fs.readFileSync(configPath, 'utf8'); let parsed; try { parsed = TOML.parse(raw); } catch (err) { console.error(`[ecc-mcp] Failed to parse ${configPath}: ${err.message}`); process.exit(1); } const existing = parsed.mcp_servers || {}; const toAppend = []; const toRemoveLog = []; for (const [name, spec] of Object.entries(ECC_SERVERS)) { const entry = existing[name]; const aliases = LEGACY_ALIASES[name] || []; const legacyName = aliases.find(a => existing[a] && typeof existing[a].command === 'string'); // Prefer canonical entry over legacy alias const hasCanonical = entry && typeof entry.command === 'string'; const resolvedEntry = hasCanonical ? entry : legacyName ? existing[legacyName] : null; // For URL-based servers (exa), check for url field instead of command const urlEntry = !resolvedEntry && entry && typeof entry.url === 'string' ? entry : null; const finalEntry = resolvedEntry || urlEntry; const resolvedLabel = hasCanonical ? name : legacyName || name; if (finalEntry) { if (updateMcp) { // --update-mcp: remove existing section (and legacy alias), will re-add below toRemoveLog.push(`mcp_servers.${resolvedLabel}`); raw = removeServerFromText(raw, resolvedLabel, existing); if (resolvedLabel !== name) { raw = removeServerFromText(raw, name, existing); } toAppend.push(spec.toml); } else { // Add-only mode: skip, but warn about drift if (legacyName && !hasCanonical) { warn(`mcp_servers.${legacyName} is a legacy name for ${name} (run with --update-mcp to migrate)`); } else if (configDiffers(finalEntry, spec.fields)) { warn(`mcp_servers.${name} differs from ECC recommendation (run with --update-mcp to refresh)`); } else { log(` [ok] mcp_servers.${name}`); } } } else { log(` [add] mcp_servers.${name}`); toAppend.push(spec.toml); } } if (toAppend.length === 0) { log('All ECC MCP servers already present. Nothing to do.'); return; } const appendText = '\n' + toAppend.join('\n\n') + '\n'; if (dryRun) { if (toRemoveLog.length > 0) { log('Dry run — would remove and re-add:'); for (const label of toRemoveLog) log(` [remove] ${label}`); } log('Dry run — would append:'); console.log(appendText); return; } // Write: for add-only, append to preserve existing content byte-for-byte. // For --update-mcp, we modified `raw` above, so write the full file + appended sections. if (updateMcp) { for (const label of toRemoveLog) log(` [update] ${label}`); const cleaned = raw.replace(/\n+$/, '\n'); fs.writeFileSync(configPath, cleaned + appendText, 'utf8'); } else { fs.appendFileSync(configPath, appendText, 'utf8'); } log(`Done. ${toAppend.length} server(s) ${updateMcp ? 'updated' : 'added'}.`); } main();