From 66b1aad3f28d8ab91e61f2df35f3502a10fded72 Mon Sep 17 00:00:00 2001 From: Conor Doherty Date: Thu, 13 Aug 2026 09:17:30 +1000 Subject: [PATCH 1/4] fix: probe POST-only Streamable HTTP MCP servers before marking them dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/hooks/mcp-health-check.js | 47 ++++++++++++++-- tests/hooks/mcp-health-check.test.js | 80 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index 475e4aa73..202071bdd 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -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, diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index fa05fa670..fe9aeacd7 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -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 From 06ac5b8a49f31c9293537636b1dbb160be72783d Mon Sep 17 00:00:00 2001 From: bengio777 Date: Mon, 10 Aug 2026 13:57:09 +0100 Subject: [PATCH 2/4] 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'); From c5ea82f6bfe60742835a8244777d672a2a5d5db9 Mon Sep 17 00:00:00 2001 From: cadenli Date: Tue, 18 Aug 2026 11:02:40 +0800 Subject: [PATCH 3/4] fix: accept standard tools list metadata --- scripts/memory-mcp.mjs | 12 +++++++-- tests/scripts/memory-mcp.test.js | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/scripts/memory-mcp.mjs b/scripts/memory-mcp.mjs index 5cde5e80e..7821efbfc 100755 --- a/scripts/memory-mcp.mjs +++ b/scripts/memory-mcp.mjs @@ -423,8 +423,16 @@ function createMemoryMcpService(options = {}) { return jsonRpcResult(message.id, {}); } if (message.method === 'tools/list') { - if (message.params && Object.keys(message.params).length > 0) { - return jsonRpcError(message.id, -32602, 'tools/list does not accept parameters.'); + const params = message.params || {}; + if ( + (Object.prototype.hasOwnProperty.call(params, '_meta') && !isRecord(params._meta)) + || ( + Object.prototype.hasOwnProperty.call(params, 'cursor') + && typeof params.cursor !== 'string' + ) + || Object.keys(params).some(key => !['cursor', '_meta'].includes(key)) + ) { + return jsonRpcError(message.id, -32602, 'Invalid tools/list parameters.'); } return jsonRpcResult(message.id, { tools: TOOL_DEFINITIONS.map(tool => ({ ...tool })), diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js index 0a3a7a7a0..a234d28a6 100644 --- a/tests/scripts/memory-mcp.test.js +++ b/tests/scripts/memory-mcp.test.js @@ -132,6 +132,7 @@ async function withClient(fn, options = {}) { const client = { listTools: () => request('tools/list'), + listToolsRaw: params => request('tools/list', params), callTool: ({ name, arguments: toolArguments }) => request( 'tools/call', { name, arguments: toolArguments } @@ -181,6 +182,51 @@ async function main() { }); }); + await test('accepts reserved tools/list params and rejects malformed values', async () => { + await withClient(async client => { + const withMeta = await client.listToolsRaw({ + _meta: { progressToken: 'progress-123' }, + }); + assert.strictEqual(withMeta.tools.length, 4); + + const withCursor = await client.listToolsRaw({ cursor: 'next-page' }); + assert.strictEqual(withCursor.tools.length, 4); + + const withCursorAndMeta = await client.listToolsRaw({ + cursor: 'next-page', + _meta: { progressToken: 'progress-456' }, + }); + assert.strictEqual(withCursorAndMeta.tools.length, 4); + + const withoutMeta = await client.listTools(); + assert.deepStrictEqual( + withoutMeta.tools.map(tool => tool.name).sort(), + ['memory_doctor', 'memory_read', 'memory_save', 'memory_search'] + ); + + for (const badMeta of [null, ['not', 'an', 'object'], 'string', 42, true]) { + await assert.rejects( + client.listToolsRaw({ _meta: badMeta }), + /-32602/, + `expected _meta=${JSON.stringify(badMeta)} to be rejected` + ); + } + + for (const badCursor of [null, {}, [], 42, true]) { + await assert.rejects( + client.listToolsRaw({ cursor: badCursor }), + /-32602/, + `expected cursor=${JSON.stringify(badCursor)} to be rejected` + ); + } + + await assert.rejects( + client.listToolsRaw({ unexpected: true }), + /-32602/ + ); + }); + }); + await test('accepts the reserved _meta param on tools/call and rejects malformed values', async () => { await withClient(async client => { // A valid `_meta` object (e.g. progressToken) must not block the tool call. From 13c476965faa18c49f94bc49cef1026715e8869a Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:02:38 -0400 Subject: [PATCH 4/4] 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. --- scripts/hooks/mcp-health-check.js | 47 ++-------------- tests/hooks/mcp-health-check.test.js | 80 ---------------------------- 2 files changed, 5 insertions(+), 122 deletions(-) diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index d6e728324..b78b5ac57 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -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, diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 34205eb69..fe86290e4 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -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