test: cover mcp health edge paths

This commit is contained in:
Affaan Mustafa 2026-04-29 18:08:45 -04:00
parent 1c2d5dd389
commit f92dc544c4

View File

@ -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');