From 786f46dad5fded521ead2d9f5d22e49e4cbdf898 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 5 Apr 2026 14:37:28 -0700 Subject: [PATCH] feat: support disabling bundled mcp servers --- README.md | 8 +++ mcp-configs/mcp-servers.json | 2 +- scripts/codex/merge-mcp-config.js | 30 ++++++++++-- scripts/lib/install/apply.js | 54 +++++++++++++++++++++ scripts/lib/mcp-config.js | 56 +++++++++++++++++++++ tests/lib/mcp-config.test.js | 62 ++++++++++++++++++++++++ tests/scripts/install-apply.test.js | 75 +++++++++++++++++++++++++++++ 7 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 scripts/lib/mcp-config.js create mode 100644 tests/lib/mcp-config.test.js diff --git a/README.md b/README.md index adc6491f..11cc92cb 100644 --- a/README.md +++ b/README.md @@ -708,6 +708,14 @@ Copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json`. Copy desired MCP server definitions from `mcp-configs/mcp-servers.json` into your official Claude Code config in `~/.claude/settings.json`, or into a project-scoped `.mcp.json` if you want repo-local MCP access. +If you already run your own copies of ECC-bundled MCPs, set: + +```bash +export ECC_DISABLED_MCPS="github,context7,exa,playwright,sequential-thinking,memory" +``` + +ECC-managed install and Codex sync flows will skip or remove those bundled servers instead of re-adding duplicates. + **Important:** Replace `YOUR_*_HERE` placeholders with your actual API keys. --- diff --git a/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index e2227dab..d163d937 100644 --- a/mcp-configs/mcp-servers.json +++ b/mcp-configs/mcp-servers.json @@ -175,7 +175,7 @@ "_comments": { "usage": "Copy the servers you need to your ~/.claude.json mcpServers section", "env_vars": "Replace YOUR_*_HERE placeholders with actual values", - "disabling": "Use disabledMcpServers array in project config to disable per-project", + "disabling": "Use ECC_DISABLED_MCPS=github,context7,... to disable bundled ECC MCPs during install/sync, or use disabledMcpServers in project config for per-project overrides", "context_warning": "Keep under 10 MCPs enabled to preserve context window" } } diff --git a/scripts/codex/merge-mcp-config.js b/scripts/codex/merge-mcp-config.js index 38138dad..0acb0085 100644 --- a/scripts/codex/merge-mcp-config.js +++ b/scripts/codex/merge-mcp-config.js @@ -19,6 +19,7 @@ const fs = require('fs'); const path = require('path'); +const { parseDisabledMcpServers } = require('../lib/mcp-config'); let TOML; try { @@ -210,6 +211,7 @@ function main() { const configPath = args.find(a => !a.startsWith('-')); const dryRun = args.includes('--dry-run'); const updateMcp = args.includes('--update-mcp'); + const disabledServers = new Set(parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS)); if (!configPath) { console.error('Usage: merge-mcp-config.js [--dry-run] [--update-mcp]'); @@ -222,6 +224,9 @@ function main() { } log(`Package manager: ${PM_NAME} (exec: ${PM_EXEC})`); + if (disabledServers.size > 0) { + log(`Disabled via ECC_DISABLED_MCPS: ${[...disabledServers].join(', ')}`); + } let raw = fs.readFileSync(configPath, 'utf8'); let parsed; @@ -249,6 +254,18 @@ function main() { const finalEntry = resolvedEntry || urlEntry; const resolvedLabel = hasCanonical ? name : legacyName || name; + if (disabledServers.has(name)) { + if (finalEntry) { + toRemoveLog.push(`mcp_servers.${resolvedLabel} (disabled)`); + raw = removeServerFromText(raw, resolvedLabel, existing); + if (resolvedLabel !== name) { + raw = removeServerFromText(raw, name, existing); + } + } + log(` [skip] mcp_servers.${name} (disabled)`); + continue; + } + if (finalEntry) { if (updateMcp) { // --update-mcp: remove existing section (and legacy alias), will re-add below @@ -278,7 +295,9 @@ function main() { } } - if (toAppend.length === 0) { + const hasRemovals = toRemoveLog.length > 0; + + if (toAppend.length === 0 && !hasRemovals) { log('All ECC MCP servers already present. Nothing to do.'); return; } @@ -297,14 +316,19 @@ function main() { // 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) { + if (updateMcp || hasRemovals) { for (const label of toRemoveLog) log(` [update] ${label}`); const cleaned = raw.replace(/\n+$/, '\n'); - fs.writeFileSync(configPath, cleaned + appendText, 'utf8'); + fs.writeFileSync(configPath, cleaned + (toAppend.length > 0 ? appendText : ''), 'utf8'); } else { fs.appendFileSync(configPath, appendText, 'utf8'); } + if (hasRemovals && toAppend.length === 0) { + log(`Done. Removed ${toRemoveLog.length} disabled server(s).`); + return; + } + log(`Done. ${toAppend.length} server(s) ${updateMcp ? 'updated' : 'added'}.`); } diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 40f3b55b..f4d66228 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { writeInstallState } = require('../install-state'); +const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); function readJsonObject(filePath, label) { let parsed; @@ -124,6 +125,49 @@ function findHooksSourcePath(plan, hooksDestinationPath) { return operation ? operation.sourcePath : null; } +function isMcpConfigPath(filePath) { + const basename = path.basename(String(filePath || '')); + return basename === '.mcp.json' || basename === 'mcp.json'; +} + +function buildFilteredMcpWrites(plan) { + const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS); + if (disabledServers.length === 0) { + return []; + } + + const writes = []; + + for (const operation of plan.operations) { + if (!isMcpConfigPath(operation.destinationPath) || !operation.sourcePath || !fs.existsSync(operation.sourcePath)) { + continue; + } + + let sourceConfig; + try { + sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); + } catch { + continue; + } + + if (!sourceConfig.mcpServers || typeof sourceConfig.mcpServers !== 'object' || Array.isArray(sourceConfig.mcpServers)) { + continue; + } + + const filtered = filterMcpConfig(sourceConfig, disabledServers); + if (filtered.removed.length === 0) { + continue; + } + + writes.push({ + destinationPath: operation.destinationPath, + filteredConfig: filtered.config, + }); + } + + return writes; +} + function buildMergedSettings(plan) { if (!plan.adapter || plan.adapter.target !== 'claude') { return null; @@ -177,6 +221,7 @@ function buildMergedSettings(plan) { function applyInstallPlan(plan) { const mergedSettingsPlan = buildMergedSettings(plan); + const filteredMcpWrites = buildFilteredMcpWrites(plan); for (const operation of plan.operations) { fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); @@ -198,6 +243,15 @@ function applyInstallPlan(plan) { ); } + for (const writePlan of filteredMcpWrites) { + fs.mkdirSync(path.dirname(writePlan.destinationPath), { recursive: true }); + fs.writeFileSync( + writePlan.destinationPath, + JSON.stringify(writePlan.filteredConfig, null, 2) + '\n', + 'utf8' + ); + } + writeInstallState(plan.installStatePath, plan.statePreview); return { diff --git a/scripts/lib/mcp-config.js b/scripts/lib/mcp-config.js new file mode 100644 index 00000000..bce45de0 --- /dev/null +++ b/scripts/lib/mcp-config.js @@ -0,0 +1,56 @@ +'use strict'; + +function parseDisabledMcpServers(value) { + return [...new Set( + String(value || '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + )]; +} + +function filterMcpConfig(config, disabledServerNames = []) { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('MCP config must be a JSON object'); + } + + const servers = config.mcpServers; + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) { + throw new Error('MCP config must include an mcpServers object'); + } + + const disabled = new Set(parseDisabledMcpServers(disabledServerNames)); + if (disabled.size === 0) { + return { + config: { + ...config, + mcpServers: { ...servers }, + }, + removed: [], + }; + } + + const nextServers = {}; + const removed = []; + + for (const [name, serverConfig] of Object.entries(servers)) { + if (disabled.has(name)) { + removed.push(name); + continue; + } + nextServers[name] = serverConfig; + } + + return { + config: { + ...config, + mcpServers: nextServers, + }, + removed, + }; +} + +module.exports = { + filterMcpConfig, + parseDisabledMcpServers, +}; diff --git a/tests/lib/mcp-config.test.js b/tests/lib/mcp-config.test.js new file mode 100644 index 00000000..76eb7fe0 --- /dev/null +++ b/tests/lib/mcp-config.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const assert = require('assert'); + +const { filterMcpConfig, parseDisabledMcpServers } = require('../../scripts/lib/mcp-config'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing mcp-config.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('parseDisabledMcpServers dedupes and trims values', () => { + assert.deepStrictEqual( + parseDisabledMcpServers(' github,exa ,github,,playwright '), + ['github', 'exa', 'playwright'] + ); + })) passed++; else failed++; + + if (test('filterMcpConfig removes disabled servers and preserves others', () => { + const result = filterMcpConfig({ + mcpServers: { + github: { command: 'npx' }, + exa: { url: 'https://mcp.exa.ai/mcp' }, + memory: { command: 'npx' }, + }, + _comments: { usage: 'demo' }, + }, ['github', 'memory']); + + assert.deepStrictEqual(result.removed, ['github', 'memory']); + assert.deepStrictEqual(Object.keys(result.config.mcpServers), ['exa']); + assert.deepStrictEqual(result.config._comments, { usage: 'demo' }); + })) passed++; else failed++; + + if (test('filterMcpConfig leaves config unchanged when no disabled servers are provided', () => { + const result = filterMcpConfig({ + mcpServers: { + github: { command: 'npx' }, + }, + }, []); + + assert.deepStrictEqual(result.removed, []); + assert.deepStrictEqual(Object.keys(result.config.mcpServers), ['github']); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 234b22e2..91b162ae 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -7,6 +7,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); +const { applyInstallPlan } = require('../../scripts/lib/install/apply'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); @@ -440,6 +441,80 @@ function runTests() { } })) passed++; else failed++; + if (test('filters copied mcp config files when ECC_DISABLED_MCPS is set', () => { + const tempDir = createTempDir('install-apply-mcp-'); + const sourcePath = path.join(tempDir, '.mcp.json'); + const destinationPath = path.join(tempDir, 'installed', '.mcp.json'); + const installStatePath = path.join(tempDir, 'installed', 'ecc-install-state.json'); + const previousValue = process.env.ECC_DISABLED_MCPS; + + try { + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, JSON.stringify({ + mcpServers: { + github: { command: 'npx' }, + exa: { url: 'https://mcp.exa.ai/mcp' }, + memory: { command: 'npx' }, + }, + }, null, 2)); + + process.env.ECC_DISABLED_MCPS = 'github,memory'; + + applyInstallPlan({ + targetRoot: path.join(tempDir, 'installed'), + installStatePath, + statePreview: { + schemaVersion: 'ecc.install.v1', + installedAt: new Date().toISOString(), + target: { + id: 'test-install', + kind: 'project', + root: path.join(tempDir, 'installed'), + installStatePath, + }, + request: { + profile: null, + modules: ['test-mcp'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['test-mcp'], + skippedModules: [], + }, + source: { + repoVersion: null, + repoCommit: null, + manifestVersion: 1, + }, + operations: [], + }, + operations: [{ + kind: 'copy-file', + moduleId: 'test-mcp', + sourcePath, + sourceRelativePath: '.mcp.json', + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + }], + }); + + const installed = readJson(destinationPath); + assert.deepStrictEqual(Object.keys(installed.mcpServers), ['exa']); + } finally { + if (previousValue === undefined) { + delete process.env.ECC_DISABLED_MCPS; + } else { + process.env.ECC_DISABLED_MCPS = previousValue; + } + cleanup(tempDir); + } + })) passed++; else failed++; + if (test('reinstall does not duplicate managed hook entries', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');