diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 92a9804a..d3546942 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -61,15 +61,29 @@ function createCommandConfig(scriptPath) { }; } -function runHook(input, env = {}) { +function buildHookEnv(env = {}) { + const merged = { + ...process.env, + ECC_HOOK_PROFILE: 'standard' + }; + + for (const [key, value] of Object.entries(env)) { + if (value === null || value === undefined) { + delete merged[key]; + } else { + merged[key] = value; + } + } + + return merged; +} + +function runHook(input, env = {}, options = {}) { const result = spawnSync('node', [script], { input: JSON.stringify(input), encoding: 'utf8', - env: { - ...process.env, - ECC_HOOK_PROFILE: 'standard', - ...env - }, + cwd: options.cwd || process.cwd(), + env: buildHookEnv(env), timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -81,15 +95,12 @@ function runHook(input, env = {}) { }; } -function runRawHook(rawInput, env = {}) { +function runRawHook(rawInput, env = {}, options = {}) { const result = spawnSync('node', [script], { input: rawInput, encoding: 'utf8', - env: { - ...process.env, - ECC_HOOK_PROFILE: 'standard', - ...env - }, + cwd: options.cwd || process.cwd(), + env: buildHookEnv(env), timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -173,6 +184,192 @@ async function runTests() { assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`); assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`); })) passed++; else failed++; + + if (test('allows truncated MCP hook input when fail-open mode is enabled', () => { + const rawInput = JSON.stringify({ tool_name: 'mcp__flaky__search', tool_input: {} }); + const result = runRawHook(rawInput, { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_HOOK_INPUT_TRUNCATED: 'true', + ECC_HOOK_INPUT_MAX_BYTES: '256', + ECC_MCP_HEALTH_FAIL_OPEN: 'yes' + }); + + assert.strictEqual(result.code, 0, 'Expected fail-open mode to allow truncated MCP input'); + assert.strictEqual(result.stdout, rawInput, 'Expected raw input passthrough on stdout'); + assert.ok(result.stderr.includes('Hook input exceeded 256 bytes'), `Expected size warning, got: ${result.stderr}`); + assert.ok(/fail-open mode is enabled/i.test(result.stderr), `Expected fail-open log, got: ${result.stderr}`); + })) passed++; else failed++; + + if (await asyncTest('uses default cwd config path and default home state path', async () => { + const tempDir = createTempDir(); + const homeDir = path.join(tempDir, 'home'); + const configDir = path.join(tempDir, '.claude'); + const configPath = path.join(configDir, 'settings.json'); + const expectedStatePath = path.join(homeDir, '.claude', 'mcp-health-cache.json'); + const serverScript = path.join(tempDir, 'default-path-server.js'); + + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n"); + writeConfig(configPath, { + mcpServers: { + cwddefault: createCommandConfig(serverScript) + } + }); + + const input = { tool_name: 'mcp__cwddefault__list', tool_input: {} }; + const result = runHook( + input, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: null, + ECC_MCP_HEALTH_STATE_PATH: null, + ECC_MCP_HEALTH_TIMEOUT_MS: '100', + HOME: homeDir, + USERPROFILE: homeDir + }, + { cwd: tempDir } + ); + + assert.strictEqual(result.code, 0, `Expected default-path server to pass, got ${result.code}: ${result.stderr}`); + assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout'); + + const state = readState(expectedStatePath); + assert.strictEqual(state.servers.cwddefault.status, 'healthy', 'Expected default home state path to be used'); + assert.strictEqual( + fs.realpathSync(state.servers.cwddefault.source), + fs.realpathSync(configPath), + 'Expected cwd .claude/settings.json config source' + ); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('uses cached healthy and unhealthy states without probing configs', () => { + const tempDir = createTempDir(); + const now = Date.now(); + const healthyStatePath = path.join(tempDir, 'healthy-state.json'); + const unhealthyStatePath = path.join(tempDir, 'unhealthy-state.json'); + + try { + fs.writeFileSync(healthyStatePath, JSON.stringify({ + version: 1, + servers: { + cached: { + status: 'healthy', + checkedAt: now, + expiresAt: now + 60000, + failureCount: 0, + nextRetryAt: now + } + } + })); + fs.writeFileSync(unhealthyStatePath, JSON.stringify({ + version: 1, + servers: { + blocked: { + status: 'unhealthy', + checkedAt: now, + expiresAt: now, + failureCount: 1, + nextRetryAt: now + 60000, + lastError: 'cached outage' + } + } + })); + + const healthy = runHook( + { tool_name: 'mcp__cached__list', tool_input: {} }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'), + ECC_MCP_HEALTH_STATE_PATH: healthyStatePath + } + ); + const unhealthy = runHook( + { tool_name: 'mcp__blocked__query', tool_input: {} }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'), + ECC_MCP_HEALTH_STATE_PATH: unhealthyStatePath + } + ); + + assert.strictEqual(healthy.code, 0, 'Expected cached healthy server to pass without config lookup'); + assert.strictEqual(healthy.stderr, '', 'Expected cached healthy server to skip logging'); + assert.strictEqual(unhealthy.code, 2, 'Expected cached unhealthy server to block before retry time'); + assert.ok(unhealthy.stderr.includes('marked unhealthy until'), `Expected cached unhealthy log, got: ${unhealthy.stderr}`); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('ignores malformed state files and allows missing MCP configs', () => { + const tempDir = createTempDir(); + const statePath = path.join(tempDir, 'malformed-state.json'); + + try { + fs.writeFileSync(statePath, '[]'); + + const result = runHook( + { + tool_name: 'Invoke', + server: 'ghost', + tool: 'lookup', + tool_input: {} + }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'), + ECC_MCP_HEALTH_STATE_PATH: statePath + } + ); + + assert.strictEqual(result.code, 0, 'Expected missing config to be non-blocking'); + assert.ok(result.stderr.includes('No MCP config found for ghost'), `Expected missing config log, got: ${result.stderr}`); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (await asyncTest('supports explicit tool_input server targets and mcp_servers config aliases', async () => { + const tempDir = createTempDir(); + const configPath = path.join(tempDir, 'claude.json'); + const statePath = path.join(tempDir, 'mcp-health.json'); + const serverScript = path.join(tempDir, 'alias-server.js'); + + try { + fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n"); + writeConfig(configPath, { + mcp_servers: { + alias: createCommandConfig(serverScript) + } + }); + + const input = { + tool_name: 'GenericMcpTool', + tool_input: { + connector: 'alias', + mcp_tool: 'lookup' + } + }; + const result = runHook(input, { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_HEALTH_TIMEOUT_MS: '100' + }); + + assert.strictEqual(result.code, 0, `Expected explicit MCP target to pass, got ${result.code}: ${result.stderr}`); + const state = readState(statePath); + assert.strictEqual(state.servers.alias.status, 'healthy', 'Expected alias server to be marked healthy'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => { const tempDir = createTempDir(); const configPath = path.join(tempDir, 'claude.json'); @@ -272,6 +469,151 @@ async function runTests() { } })) passed++; else failed++; + if (await asyncTest('blocks unsupported MCP configs and command spawn failures', async () => { + const tempDir = createTempDir(); + const configPath = path.join(tempDir, 'claude.json'); + const statePath = path.join(tempDir, 'mcp-health.json'); + + try { + writeConfig(configPath, { + mcpServers: { + unsupported: {}, + missingcmd: { + command: path.join(tempDir, 'missing-mcp-server') + } + } + }); + + const unsupported = runHook( + { tool_name: 'mcp__unsupported__search', tool_input: {} }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_HEALTH_TIMEOUT_MS: '100' + } + ); + const missingCommand = runHook( + { tool_name: 'mcp__missingcmd__search', tool_input: {} }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_HEALTH_TIMEOUT_MS: '100' + } + ); + + assert.strictEqual(unsupported.code, 2, 'Expected unsupported config to block'); + assert.ok(unsupported.stderr.includes('unsupported MCP server config'), `Expected unsupported reason, got: ${unsupported.stderr}`); + assert.strictEqual(missingCommand.code, 2, 'Expected missing command to block'); + assert.ok(/ENOENT|spawn/i.test(missingCommand.stderr), `Expected spawn failure reason, got: ${missingCommand.stderr}`); + + const state = readState(statePath); + assert.strictEqual(state.servers.unsupported.status, 'unhealthy', 'Expected unsupported server state'); + assert.strictEqual(state.servers.missingcmd.status, 'unhealthy', 'Expected missing command server state'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (await asyncTest('includes command stderr and config env in unhealthy probe reasons', async () => { + const tempDir = createTempDir(); + const configPath = path.join(tempDir, 'claude.json'); + const statePath = path.join(tempDir, 'mcp-health.json'); + const serverScript = path.join(tempDir, 'stderr-server.js'); + + try { + fs.writeFileSync( + serverScript, + "console.error(`probe failed with ${process.env.ECC_MCP_TEST_MARKER}`); process.exit(1);\n" + ); + writeConfig(configPath, { + mcpServers: { + stderrprobe: { + command: process.execPath, + args: [serverScript], + env: { + ECC_MCP_TEST_MARKER: 'marker-from-config' + } + } + } + }); + + const result = runHook( + { tool_name: 'mcp__stderrprobe__search', tool_input: {} }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_HEALTH_TIMEOUT_MS: '100' + } + ); + + assert.strictEqual(result.code, 2, 'Expected stderr probe failure to block'); + assert.ok(result.stderr.includes('marker-from-config'), `Expected command stderr in reason, got: ${result.stderr}`); + + const state = readState(statePath); + assert.ok(state.servers.stderrprobe.lastError.includes('marker-from-config'), 'Expected stderr reason in state'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (await asyncTest('records reconnect reprobe failures for previously unhealthy servers', async () => { + const tempDir = createTempDir(); + const configPath = path.join(tempDir, 'claude.json'); + const statePath = path.join(tempDir, 'mcp-health.json'); + const serverScript = path.join(tempDir, 'still-down-server.js'); + const reconnectScript = path.join(tempDir, 'noop-reconnect.js'); + const now = Date.now(); + + try { + fs.writeFileSync(serverScript, "console.error('503 Service Unavailable'); process.exit(1);\n"); + fs.writeFileSync(reconnectScript, "process.exit(0);\n"); + fs.writeFileSync(statePath, JSON.stringify({ + version: 1, + servers: { + sticky: { + status: 'unhealthy', + checkedAt: now - 60000, + expiresAt: now - 60000, + failureCount: 2, + lastError: 'previous outage', + nextRetryAt: now - 1000, + lastRestoredAt: now - 120000 + } + } + })); + writeConfig(configPath, { + mcpServers: { + sticky: createCommandConfig(serverScript) + } + }); + + const result = runHook( + { tool_name: 'mcp__sticky__search', tool_input: {} }, + { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`, + ECC_MCP_HEALTH_TIMEOUT_MS: '100', + ECC_MCP_HEALTH_BACKOFF_MS: '10' + } + ); + + assert.strictEqual(result.code, 2, 'Expected still-unhealthy server to block'); + assert.ok(result.stderr.includes('reconnect reprobe failed'), `Expected reprobe failure reason, got: ${result.stderr}`); + assert.ok(result.stderr.includes('Reconnect attempt: ok'), `Expected reconnect attempt suffix, got: ${result.stderr}`); + + const state = readState(statePath); + assert.strictEqual(state.servers.sticky.failureCount, 3, 'Expected failure count to increment'); + assert.strictEqual(state.servers.sticky.lastRestoredAt, now - 120000, 'Expected previous restore timestamp to survive'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + if (await asyncTest('post-failure reconnect command restores server health when a reprobe succeeds', async () => { const tempDir = createTempDir(); const configPath = path.join(tempDir, 'claude.json'); @@ -334,6 +676,131 @@ async function runTests() { } })) passed++; else failed++; + if (test('ignores post-failure events without a reconnect-worthy failure code', () => { + const tempDir = createTempDir(); + const statePath = path.join(tempDir, 'mcp-health.json'); + + try { + const result = runHook( + { + tool_name: 'mcp__quiet__messages', + tool_input: {}, + error: 'tool returned an application-level validation error' + }, + { + CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', + ECC_MCP_HEALTH_STATE_PATH: statePath + } + ); + + assert.strictEqual(result.code, 0, 'Expected unmatched post-failure to remain non-blocking'); + assert.strictEqual(result.stderr, '', 'Expected no logs for unmatched post-failure'); + assert.strictEqual(fs.existsSync(statePath), false, 'Expected no state write for unmatched post-failure'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('post-failure marks servers unhealthy and skips reconnect when no command is configured', () => { + const tempDir = createTempDir(); + const statePath = path.join(tempDir, 'mcp-health.json'); + + try { + const result = runHook( + { + tool_name: 'mcp__noplan__messages', + tool_input: {}, + tool_output: { + stderr: '403 Forbidden from upstream MCP' + } + }, + { + CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_RECONNECT_COMMAND: null + } + ); + + assert.strictEqual(result.code, 0, 'Expected post-failure hook to remain non-blocking'); + assert.ok(result.stderr.includes('reported 403'), `Expected detected failure code log, got: ${result.stderr}`); + assert.ok(result.stderr.includes('reconnect skipped'), `Expected reconnect skipped log, got: ${result.stderr}`); + + const state = readState(statePath); + assert.strictEqual(state.servers.noplan.status, 'unhealthy', 'Expected post-failure to mark server unhealthy'); + assert.strictEqual(state.servers.noplan.lastFailureCode, 403, 'Expected detected status code in state'); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('post-failure reports failed reconnect commands', () => { + const tempDir = createTempDir(); + const statePath = path.join(tempDir, 'mcp-health.json'); + const reconnectScript = path.join(tempDir, 'failed-reconnect.js'); + + try { + fs.writeFileSync(reconnectScript, "console.error('cannot reconnect'); process.exit(7);\n"); + + const result = runHook( + { + tool_name: 'mcp__badreconnect__messages', + tool_input: {}, + tool_response: 'service unavailable 503' + }, + { + CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}` + } + ); + + assert.strictEqual(result.code, 0, 'Expected reconnect failure hook to remain non-blocking'); + assert.ok(result.stderr.includes('reported 503'), `Expected detected failure code log, got: ${result.stderr}`); + assert.ok(result.stderr.includes('reconnect failed: cannot reconnect'), `Expected reconnect failure reason, got: ${result.stderr}`); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('post-failure expands per-server reconnect commands before follow-up config checks', () => { + const tempDir = createTempDir(); + const statePath = path.join(tempDir, 'mcp-health.json'); + const reconnectScript = path.join(tempDir, 'server-reconnect.js'); + const markerFile = path.join(tempDir, 'server-name.txt'); + + try { + fs.writeFileSync( + reconnectScript, + [ + "const fs = require('fs');", + "fs.writeFileSync(process.argv[2], process.argv[3]);" + ].join('\n') + ); + + const result = runHook( + { + tool_name: 'mcp__foo-bar__messages', + tool_input: {}, + message: 'transport connection reset' + }, + { + CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'), + ECC_MCP_RECONNECT_COMMAND: null, + ECC_MCP_RECONNECT_FOO_BAR: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)} ${JSON.stringify(markerFile)} {server}` + } + ); + + assert.strictEqual(result.code, 0, 'Expected per-server reconnect hook to remain non-blocking'); + assert.strictEqual(fs.readFileSync(markerFile, 'utf8'), 'foo-bar', 'Expected {server} token expansion'); + assert.ok(result.stderr.includes('reported transport'), `Expected transport failure log, got: ${result.stderr}`); + assert.ok(result.stderr.includes('no config was available'), `Expected missing config follow-up log, got: ${result.stderr}`); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + if (await asyncTest('treats HTTP 400 probe responses as healthy reachable servers', async () => { const tempDir = createTempDir(); const configPath = path.join(tempDir, 'claude.json');