From 06ac5b8a49f31c9293537636b1dbb160be72783d Mon Sep 17 00:00:00 2001 From: bengio777 Date: Mon, 10 Aug 2026 13:57:09 +0100 Subject: [PATCH] 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. --- scripts/hooks/mcp-health-check.js | 7 ++- tests/hooks/mcp-health-check.test.js | 71 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index 202071bdd..d6e728324 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -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 }, diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index fe9aeacd7..34205eb69 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -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');