mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
fix: probe POST-only Streamable HTTP MCP servers before marking them dead
The preflight probe in mcp-health-check only ever sent a bare GET to the server URL. Some Streamable HTTP MCP servers route POST exclusively and answer any GET with 404 — api.telnyx.com/v2/mcp is one — so the probe failed permanently against a perfectly healthy server. 404 is not in HEALTHY_HTTP_CODES, so every probe failed, the backoff compounded to the 10-minute ceiling, and the hook blocked every tool call for that server before it left the machine while `claude mcp list` still reported it Connected. Replay a failed GET as a real JSON-RPC initialize POST and accept that as proof of life. Whitelisting 404 was the alternative, but it would mask genuine outages on every other server. Adds a regression test with a POST-only server that 404s all GETs and validates the initialize body; it fails without this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Alex Schmitt
co-authored by
Claude Opus 5
parent
d8409a4b08
commit
66b1aad3f2
@@ -253,19 +253,28 @@ function detectFailureCode(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestHttp(urlString, headers, timeoutMs) {
|
||||
function requestHttp(urlString, headers, timeoutMs, options = {}) {
|
||||
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: 'GET',
|
||||
headers,
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
res => {
|
||||
if (settled) return;
|
||||
@@ -294,7 +303,23 @@ function requestHttp(urlString, headers, timeoutMs) {
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
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' }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -513,7 +538,19 @@ async function probeServer(serverName, resolvedConfig) {
|
||||
const config = resolvedConfig.config;
|
||||
|
||||
if (config.type === 'http' || config.url) {
|
||||
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
|
||||
@@ -1024,6 +1024,86 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user