mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-29 11:19:39 +02:00
Merge pull request #2869 from actus7/consolidate/mcp-health-v3
fix(hooks): consolidate MCP health-check fixes (3 PRs)
This commit is contained in:
@@ -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 },
|
||||
|
||||
+10
-2
@@ -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 })),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user