fix(hooks): keep MCP reachability probes bounded

Remove the redundant JSON-RPC initialize fallback from the consolidated MCP health-check batch. A routed 404 already proves the endpoint is reachable, and the real authenticated MCP call remains authoritative. Avoiding the fallback also prevents a stalled GET plus stalled POST from consuming twice the configured hook timeout.
This commit is contained in:
haelyra
2026-08-28 21:09:35 -04:00
parent c5ea82f6bf
commit 13c476965f
2 changed files with 5 additions and 122 deletions
+5 -42
View File
@@ -256,28 +256,19 @@ function detectFailureCode(text) {
return null;
}
function requestHttp(urlString, headers, timeoutMs, options = {}) {
function requestHttp(urlString, headers, timeoutMs) {
return new Promise(resolve => {
let settled = false;
let timedOut = false;
const url = new URL(urlString);
const client = url.protocol === 'https:' ? https : http;
const method = options.method || 'GET';
const body = options.body || null;
const requestHeaders = { ...headers };
if (body) {
requestHeaders['content-type'] = 'application/json';
requestHeaders['content-length'] = Buffer.byteLength(body);
requestHeaders.accept = 'application/json, text/event-stream';
}
const req = client.request(
url,
{
method,
headers: requestHeaders,
method: 'GET',
headers,
},
res => {
if (settled) return;
@@ -306,23 +297,7 @@ function requestHttp(urlString, headers, timeoutMs, options = {}) {
});
});
req.end(body || undefined);
});
}
// Some Streamable HTTP MCP servers (e.g. api.telnyx.com/v2/mcp) only route POST
// and answer any GET with 404, so a bare GET proves nothing. Replay the probe as
// a real JSON-RPC initialize before declaring the server unreachable.
function mcpInitializeBody() {
return JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'ecc-mcp-health-check', version: '1' }
}
req.end();
});
}
@@ -541,19 +516,7 @@ async function probeServer(serverName, resolvedConfig) {
const config = resolvedConfig.config;
if (config.type === 'http' || config.url) {
const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
let result = await requestHttp(config.url, config.headers || {}, timeoutMs);
if (!result.ok) {
const posted = await requestHttp(config.url, config.headers || {}, timeoutMs, {
method: 'POST',
body: mcpInitializeBody()
});
if (posted.ok) {
result = posted;
}
}
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
return {
ok: result.ok,
-80
View File
@@ -1095,86 +1095,6 @@ async function runTests() {
}
})) passed++; else failed++;
if (await asyncTest('treats POST-only Streamable HTTP MCP servers that answer every GET with 404 as healthy', async () => {
const tempDir = createTempDir();
const configPath = path.join(tempDir, 'claude.json');
const statePath = path.join(tempDir, 'mcp-health.json');
const serverScript = path.join(tempDir, 'http-post-only-server.js');
const portFile = path.join(tempDir, 'server-port.txt');
fs.writeFileSync(
serverScript,
[
"const fs = require('fs');",
"const http = require('http');",
"const portFile = process.argv[2];",
"const server = http.createServer((req, res) => {",
" if (req.method !== 'POST') {",
" res.writeHead(404, { 'Content-Type': 'application/json' });",
" res.end(JSON.stringify({ error: 'not found' }));",
" return;",
" }",
" let body = '';",
" req.on('data', chunk => { body += chunk; });",
" req.on('end', () => {",
" let parsed = null;",
" try { parsed = JSON.parse(body); } catch { parsed = null; }",
" if (!parsed || parsed.jsonrpc !== '2.0' || parsed.method !== 'initialize') {",
" res.writeHead(400, { 'Content-Type': 'application/json' });",
" res.end(JSON.stringify({ error: 'expected a JSON-RPC initialize body' }));",
" return;",
" }",
" res.writeHead(200, { 'Content-Type': 'application/json' });",
" res.end(JSON.stringify({ jsonrpc: '2.0', id: parsed.id, result: {} }));",
" });",
"});",
"server.listen(0, '127.0.0.1', () => {",
" fs.writeFileSync(portFile, String(server.address().port));",
"});",
"setInterval(() => {}, 1000);"
].join('\n')
);
const serverProcess = spawn(process.execPath, [serverScript, portFile], {
stdio: 'ignore'
});
try {
const port = waitForFile(portFile).trim();
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
writeConfig(configPath, {
mcpServers: {
postonly: {
type: 'http',
url: `http://127.0.0.1:${port}/mcp`
}
}
});
const input = { tool_name: 'mcp__postonly__list_api_endpoints', tool_input: {} };
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: '2000'
});
assert.strictEqual(
result.code,
0,
`Expected POST-only MCP server to survive a 404 GET probe: ${hookFailureDetails(result, statePath)}`
);
assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout');
const state = readState(statePath);
assert.strictEqual(state.servers.postonly.status, 'healthy', 'Expected POST-only MCP server to be marked healthy');
} finally {
serverProcess.kill('SIGTERM');
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
// Windows-only: child_process.spawn cannot resolve .cmd/.bat shims for
// bare PATH commands without an extension, and Node 18.20+/20.12+ refuse
// to spawn .cmd targets without `shell: true` (CVE-2024-27980). The probe