fix(hooks): treat HTTP 404 MCP probes as reachable

The mcp-health-check preflight probes HTTP MCP servers with a bare GET.
Some Streamable HTTP servers route only POST /mcp and answer a bare GET
with 404 (Paper Desktop 0.5.3 is one). The probe scored that as down and
blocked every tool call for the server indefinitely, since the 30s
backoff just re-probes and re-fails.

A routed HTTP response of any status proves the endpoint is reachable,
which is all this preflight claims to check -- 400/401/403/405/406 are
already treated this way for the same reason. Add 404 to the set and let
the real MCP client validate the endpoint.

Adds a regression test that stands up a POST-only server (404 on GET,
200 on POST /mcp); it fails on the current code and passes with the fix.
This commit is contained in:
bengio777
2026-08-24 22:27:30 -03:00
committed by Alex Schmitt
parent 66b1aad3f2
commit 06ac5b8a49
2 changed files with 76 additions and 2 deletions
+5 -2
View File
@@ -28,8 +28,11 @@ const MAX_BACKOFF_MS = 10 * 60 * 1000;
// Claude Code's stored OAuth bearer token. Treat auth-gated responses as
// reachable so the real MCP client can attempt the authenticated call. A
// Streamable HTTP MCP server can also return 406 to a bare GET that omits
// Accept: text/event-stream; that still proves the endpoint is alive.
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 405, 406]);
// Accept: text/event-stream; that still proves the endpoint is alive. Some
// POST-only Streamable HTTP servers (e.g. Paper Desktop) answer a bare GET
// with 404 instead; a routed HTTP response of any kind proves reachability,
// so treat 404 as alive and let the real MCP client validate the endpoint.
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404, 405, 406]);
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
const FAILURE_PATTERNS = [
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },
+71
View File
@@ -888,6 +888,77 @@ async function runTests() {
}
})) passed++; else failed++;
if (await asyncTest('treats HTTP 404 probe responses as healthy POST-only Streamable HTTP 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, 'http-404-server.js');
const portFile = path.join(tempDir, 'server-port.txt');
// Mirrors Paper Desktop: the Streamable HTTP endpoint only routes POST and
// answers a bare GET probe with 404, which still proves reachability.
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' && req.url === '/mcp') {",
" res.writeHead(200, { 'Content-Type': 'text/event-stream' });",
" res.end('event: message\\ndata: {}\\n\\n');",
" return;",
" }",
" res.writeHead(404, { 'Content-Type': 'text/plain' });",
" res.end('not found');",
"});",
"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: {
http404: {
type: 'http',
url: `http://127.0.0.1:${port}/mcp`
}
}
});
const input = { tool_name: 'mcp__http404__get_guide', 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 HTTP 404 probe to be treated as healthy: ${hookFailureDetails(result, statePath)}`
);
assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout');
const state = readState(statePath);
assert.strictEqual(state.servers.http404.status, 'healthy', 'Expected POST-only HTTP MCP server to be marked healthy');
} finally {
serverProcess.kill('SIGTERM');
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
if (await asyncTest('treats HTTP 401 probe responses as healthy reachable OAuth-protected servers', async () => {
const tempDir = createTempDir();
const configPath = path.join(tempDir, 'claude.json');