From 92e0c7e9ff9202b60da34659204420aea487f71f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 21 Apr 2026 18:35:21 -0400 Subject: [PATCH] fix: install native Cursor hook and MCP config (#1543) * fix: install native cursor hook and MCP config * fix: avoid false healthy stdio mcp probes --- .cursor/hooks.json | 1 + scripts/hooks/mcp-health-check.js | 19 ++- scripts/lib/install-executor.js | 55 ++++++++- scripts/lib/install-targets/cursor-project.js | 56 ++++++++- scripts/lib/install/apply.js | 109 ++++++++++-------- tests/lib/install-manifests.test.js | 9 ++ tests/lib/install-targets.test.js | 15 +++ tests/scripts/install-apply.test.js | 37 ++++++ 8 files changed, 249 insertions(+), 52 deletions(-) diff --git a/.cursor/hooks.json b/.cursor/hooks.json index cbe4d346..573e647f 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -1,4 +1,5 @@ { + "version": 1, "hooks": { "sessionStart": [ { diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index 4c40e114..c47d3880 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -308,10 +308,15 @@ function probeCommandServer(serverName, config) { let stderr = ''; let done = false; + let timer = null; function finish(result) { if (done) return; done = true; + if (timer) { + clearTimeout(timer); + timer = null; + } resolve(result); } @@ -354,7 +359,19 @@ function probeCommandServer(serverName, config) { }); }); - const timer = setTimeout(() => { + timer = setTimeout(() => { + // A fast-crashing stdio server can finish before the timer callback runs + // on a loaded machine. Check the process state again before classifying it + // as healthy on timeout. + if (child.exitCode !== null || child.signalCode !== null) { + finish({ + ok: false, + statusCode: child.exitCode, + reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})` + }); + return; + } + try { child.kill('SIGTERM'); } catch { diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 7ad7a408..d6676c56 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -184,6 +184,41 @@ function addFileCopyOperation(operations, options) { return true; } +function readJsonObject(filePath, label) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`); + } + + return parsed; +} + +function addJsonMergeOperation(operations, options) { + const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath); + if (!fs.existsSync(sourcePath)) { + return false; + } + + operations.push({ + kind: 'merge-json', + moduleId: options.moduleId, + sourceRelativePath: options.sourceRelativePath, + destinationPath: options.destinationPath, + strategy: 'merge-json', + ownership: 'managed', + scaffoldOnly: false, + mergePayload: readJsonObject(sourcePath, options.sourceRelativePath), + }); + + return true; +} + function addMatchingRuleOperations(operations, options) { const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir); if (!fs.existsSync(sourceDir)) { @@ -342,10 +377,10 @@ function planCursorLegacyInstall(context) { sourceRelativePath: path.join('.cursor', 'hooks.json'), destinationPath: path.join(targetRoot, 'hooks.json'), }); - addFileCopyOperation(operations, { + addJsonMergeOperation(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, - sourceRelativePath: path.join('.cursor', 'mcp.json'), + sourceRelativePath: '.mcp.json', destinationPath: path.join(targetRoot, 'mcp.json'), }); @@ -540,6 +575,22 @@ function createLegacyCompatInstallPlan(options = {}) { } function materializeScaffoldOperation(sourceRoot, operation) { + if (operation.kind === 'merge-json') { + return [{ + kind: 'merge-json', + moduleId: operation.moduleId, + sourceRelativePath: operation.sourceRelativePath, + destinationPath: operation.destinationPath, + strategy: operation.strategy || 'merge-json', + ownership: operation.ownership || 'managed', + scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false, + mergePayload: readJsonObject( + path.join(sourceRoot, operation.sourceRelativePath), + operation.sourceRelativePath + ), + }]; + } + const sourcePath = path.join(sourceRoot, operation.sourceRelativePath); if (!fs.existsSync(sourcePath)) { return []; diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index ceba1cb7..66fd19df 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -18,6 +18,39 @@ function toCursorRuleFileName(fileName, sourceRelativeFile) { : fileName; } +function readJsonObject(filePath, label) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`); + } + + return parsed; +} + +function createJsonMergeOperation({ moduleId, repoRoot, sourceRelativePath, destinationPath }) { + const sourcePath = path.join(repoRoot, sourceRelativePath); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) { + return null; + } + + return createManagedOperation({ + kind: 'merge-json', + moduleId, + sourceRelativePath, + destinationPath, + strategy: 'merge-json', + ownership: 'managed', + scaffoldOnly: false, + mergePayload: readJsonObject(sourcePath, sourceRelativePath), + }); +} + module.exports = createInstallTargetAdapter({ id: 'cursor-project', target: 'cursor', @@ -93,6 +126,13 @@ module.exports = createInstallTargetAdapter({ } return entries.flatMap(({ module, sourceRelativePath }) => { + const cursorMcpOperation = createJsonMergeOperation({ + moduleId: module.id, + repoRoot, + sourceRelativePath: '.mcp.json', + destinationPath: path.join(targetRoot, 'mcp.json'), + }); + if (sourceRelativePath === 'rules') { return takeUniqueOperations(createFlatRuleOperations({ moduleId: module.id, @@ -127,7 +167,21 @@ module.exports = createInstallTargetAdapter({ destinationNameTransform: toCursorRuleFileName, }); - return takeUniqueOperations([...childOperations, ...ruleOperations]); + return takeUniqueOperations([ + ...childOperations, + ...(cursorMcpOperation ? [cursorMcpOperation] : []), + ...ruleOperations, + ]); + } + + if (sourceRelativePath === 'mcp-configs') { + const operations = [ + adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput), + ]; + if (cursorMcpOperation) { + operations.push(cursorMcpOperation); + } + return takeUniqueOperations(operations); } return takeUniqueOperations([ diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index b74e73f3..42497c42 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -21,6 +21,38 @@ function readJsonObject(filePath, label) { return parsed; } +function cloneJsonValue(value) { + if (value === undefined) { + return undefined; + } + + return JSON.parse(JSON.stringify(value)); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function deepMergeJson(baseValue, patchValue) { + if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) { + return cloneJsonValue(patchValue); + } + + const merged = { ...baseValue }; + for (const [key, value] of Object.entries(patchValue)) { + if (isPlainObject(value) && isPlainObject(merged[key])) { + merged[key] = deepMergeJson(merged[key], value); + } else { + merged[key] = cloneJsonValue(value); + } + } + return merged; +} + +function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + function replacePluginRootPlaceholders(value, pluginRoot) { if (!pluginRoot) { return value; @@ -56,44 +88,6 @@ function isMcpConfigPath(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 buildResolvedClaudeHooks(plan) { if (!plan.adapter || plan.adapter.target !== 'claude') { return null; @@ -123,10 +117,38 @@ function buildResolvedClaudeHooks(plan) { function applyInstallPlan(plan) { const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan); - const filteredMcpWrites = buildFilteredMcpWrites(plan); + const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS); for (const operation of plan.operations) { fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); + + if (operation.kind === 'merge-json') { + const payload = cloneJsonValue(operation.mergePayload); + if (payload === undefined) { + throw new Error(`Missing merge payload for ${operation.destinationPath}`); + } + + const filteredPayload = ( + isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0 + ) + ? filterMcpConfig(payload, disabledServers).config + : payload; + + const currentValue = fs.existsSync(operation.destinationPath) + ? readJsonObject(operation.destinationPath, 'existing JSON config') + : {}; + const mergedValue = deepMergeJson(currentValue, filteredPayload); + fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); + continue; + } + + if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) { + const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); + const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config; + fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8'); + continue; + } + fs.copyFileSync(operation.sourcePath, operation.destinationPath); } @@ -139,15 +161,6 @@ 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/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index 652567ef..d8a79060 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -122,6 +122,15 @@ function runTests() { )), 'Should preserve non-rule Cursor platform files' ); + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === '.mcp.json' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'mcp.json') + && operation.kind === 'merge-json' + && operation.strategy === 'merge-json' + )), + 'Should materialize Cursor MCP config at the native project path' + ); assert.ok( plan.operations.some(operation => ( operation.sourceRelativePath === '.cursor/rules/common-agents.md' diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index f26ce3f5..e0908a62 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -93,6 +93,9 @@ function runTests() { const hooksJson = plan.operations.find(operation => ( normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' )); + const mcpJson = plan.operations.find(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.mcp.json' + )); const preserved = plan.operations.find(operation => ( normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-coding-style.md' )); @@ -100,6 +103,10 @@ function runTests() { assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files'); assert.strictEqual(hooksJson.strategy, 'preserve-relative-path'); assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json')); + assert.ok(mcpJson, 'Should materialize a Cursor MCP config from the shared root MCP config'); + assert.strictEqual(mcpJson.kind, 'merge-json'); + assert.strictEqual(mcpJson.strategy, 'merge-json'); + assert.strictEqual(mcpJson.destinationPath, path.join(projectRoot, '.cursor', 'mcp.json')); assert.ok(preserved, 'Should include flattened Cursor rule scaffold operations'); assert.strictEqual(preserved.strategy, 'flatten-copy'); @@ -201,6 +208,14 @@ function runTests() { )), 'Should preserve non-rule Cursor platform config files' ); + assert.ok( + plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.mcp.json' + && operation.kind === 'merge-json' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'mcp.json') + )), + 'Should materialize a project-level Cursor MCP config' + ); assert.ok( !plan.operations.some(operation => ( operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'README.mdc') diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 4943ef93..cb505e6f 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -138,11 +138,19 @@ function runTests() { assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'mcp.json'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'scripts', 'lib', 'utils.js'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md'))); + const hooksConfig = readJson(path.join(projectDir, '.cursor', 'hooks.json')); + const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json')); + assert.strictEqual(hooksConfig.version, 1); + assert.ok(hooksConfig.hooks.sessionStart, 'Should keep Cursor sessionStart hooks'); + assert.ok(mcpConfig.mcpServers.github, 'Should install shared MCP servers into Cursor'); + assert.ok(mcpConfig.mcpServers.context7, 'Should include bundled documentation MCPs'); + const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json'); const state = readJson(statePath); const normalizedProjectDir = fs.realpathSync(projectDir); @@ -163,6 +171,35 @@ function runTests() { } })) passed++; else failed++; + if (test('installs Cursor MCP config by merging bundled servers into an existing mcp.json', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const cursorRoot = path.join(projectDir, '.cursor'); + fs.mkdirSync(cursorRoot, { recursive: true }); + fs.writeFileSync(path.join(cursorRoot, 'mcp.json'), JSON.stringify({ + mcpServers: { + custom: { + command: 'node', + args: ['custom-mcp.js'], + }, + }, + }, null, 2)); + + const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json')); + assert.ok(mcpConfig.mcpServers.custom, 'Should preserve existing custom Cursor MCP servers'); + assert.ok(mcpConfig.mcpServers.github, 'Should merge bundled GitHub MCP server'); + assert.ok(mcpConfig.mcpServers.playwright, 'Should merge bundled Playwright MCP server'); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('installs Antigravity configs and writes install-state', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');