diff --git a/plugins/config-audit/scanners/lib/active-config-reader.mjs b/plugins/config-audit/scanners/lib/active-config-reader.mjs index b5e8442..15ef8a1 100644 --- a/plugins/config-audit/scanners/lib/active-config-reader.mjs +++ b/plugins/config-audit/scanners/lib/active-config-reader.mjs @@ -595,48 +595,119 @@ export async function readActiveMcpServers(repoPath, claudeJsonSlice = null, plu // Project .mcp.json const projMcp = join(repoPath, '.mcp.json'); - await collectMcpFromFile(projMcp, '.mcp.json', disabled, out); + await collectMcpFromFile(projMcp, '.mcp.json', disabled, out, repoPath); // ~/.claude.json project slice for (const [name, def] of Object.entries(slice.mcpServers || {})) { - const toolCount = Array.isArray(def?.tools) ? def.tools.length : 0; + const detected = await detectMcpToolCount(name, def, repoPath); + const toolCount = detected.toolCount; out.push({ name, source: '~/.claude.json:projects', command: describeMcpCommand(def), enabled: !disabled.has(name), disabledBy: disabled.has(name) ? 'disabledMcpjsonServers' : null, - estimatedTokens: estimateTokens(0, 'mcp', { toolCount }), + toolCount, + toolCountUnknown: detected.toolCountUnknown, + estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }), }); } // Plugin .mcp.json files for (const p of pluginList) { const pluginMcp = join(p.path, '.mcp.json'); - await collectMcpFromFile(pluginMcp, `plugin:${p.name}`, disabled, out); + await collectMcpFromFile(pluginMcp, `plugin:${p.name}`, disabled, out, repoPath); } return out; } -async function collectMcpFromFile(path, source, disabled, out) { +async function collectMcpFromFile(path, source, disabled, out, repoPath) { let content; try { content = await readFile(path, 'utf-8'); } catch { return; } const parsed = parseJson(content); if (!parsed || !parsed.mcpServers || typeof parsed.mcpServers !== 'object') return; for (const [name, def] of Object.entries(parsed.mcpServers)) { - const toolCount = Array.isArray(def?.tools) ? def.tools.length : 0; + const detected = await detectMcpToolCount(name, def, repoPath); + const toolCount = detected.toolCount; out.push({ name, source, command: describeMcpCommand(def), enabled: !disabled.has(name), disabledBy: disabled.has(name) ? 'disabledMcpjsonServers' : null, - estimatedTokens: estimateTokens(0, 'mcp', { toolCount }), + toolCount, + toolCountUnknown: detected.toolCountUnknown, + estimatedTokens: estimateTokens(0, 'mcp', { toolCount: toolCount ?? 0 }), }); } } +/** + * Detect tool count for an MCP server in this priority order (v5 M1): + * 1. Explicit `tools` array on the server definition (legacy in-config form) + * 2. Cached `tools/list` response at $HOME/.claude/config-audit/mcp-cache/.json + * 3. `tools` array in the npm package's package.json (resolved from + * /node_modules//package.json when the command is `npx `) + * 4. Fallback: { toolCount: null, toolCountUnknown: true } + * + * @param {string} name + * @param {object} def + * @param {string} repoPath + * @returns {Promise<{toolCount: number|null, toolCountUnknown: boolean}>} + */ +async function detectMcpToolCount(name, def, repoPath) { + // 1. In-config tools array + if (Array.isArray(def?.tools)) { + return { toolCount: def.tools.length, toolCountUnknown: false }; + } + + // 2. Cached tools/list response + const home = process.env.HOME || process.env.USERPROFILE || ''; + if (home) { + const cachePath = join(home, '.claude', 'config-audit', 'mcp-cache', `${name}.json`); + try { + const cacheContent = await readFile(cachePath, 'utf-8'); + const parsedCache = parseJson(cacheContent); + if (parsedCache && Array.isArray(parsedCache.tools)) { + return { toolCount: parsedCache.tools.length, toolCountUnknown: false }; + } + } catch { /* cache miss */ } + } + + // 3. node_modules package.json + const pkgName = extractNpmPackageName(def); + if (pkgName) { + const pkgPath = join(repoPath, 'node_modules', pkgName, 'package.json'); + try { + const pkgContent = await readFile(pkgPath, 'utf-8'); + const parsedPkg = parseJson(pkgContent); + if (parsedPkg && Array.isArray(parsedPkg.tools)) { + return { toolCount: parsedPkg.tools.length, toolCountUnknown: false }; + } + } catch { /* not installed */ } + } + + // 4. Unknown + return { toolCount: null, toolCountUnknown: true }; +} + +/** + * Extract npm package name from an MCP server definition launched via npx. + * Skips npx flags (`-y`, `--yes`, `--package=...`); returns the first arg + * that looks like a package name. + */ +function extractNpmPackageName(def) { + if (!def || typeof def !== 'object') return null; + if (def.command !== 'npx' || !Array.isArray(def.args)) return null; + for (const a of def.args) { + if (typeof a !== 'string') continue; + if (a.startsWith('-')) continue; + return a; + } + return null; +} + function describeMcpCommand(def) { if (!def || typeof def !== 'object') return ''; if (def.type === 'http' || def.type === 'sse') return def.url || ''; diff --git a/plugins/config-audit/tests/fixtures/mcp-tool-heavy/.mcp.json b/plugins/config-audit/tests/fixtures/mcp-tool-heavy/.mcp.json new file mode 100644 index 0000000..f93d02f --- /dev/null +++ b/plugins/config-audit/tests/fixtures/mcp-tool-heavy/.mcp.json @@ -0,0 +1,6 @@ +{ + "mcpServers": { + "heavy": { "command": "npx", "args": ["mcp-heavy"] }, + "light": { "command": "npx", "args": ["mcp-light"] } + } +} diff --git a/plugins/config-audit/tests/lib/active-config-reader.test.mjs b/plugins/config-audit/tests/lib/active-config-reader.test.mjs index bea8aaa..b977f9b 100644 --- a/plugins/config-audit/tests/lib/active-config-reader.test.mjs +++ b/plugins/config-audit/tests/lib/active-config-reader.test.mjs @@ -539,6 +539,70 @@ describe('readActiveMcpServers', () => { }); }); +// ───────────────────────────────────────────────────────────────────────── +// readActiveMcpServers — tool-count detection (v5 M1) +// ───────────────────────────────────────────────────────────────────────── + +describe('readActiveMcpServers — tool-count detection (v5 M1)', () => { + it('detects toolCount from project node_modules//package.json', async () => { + const fixturePath = resolve(import.meta.dirname || dirname(new URL(import.meta.url).pathname), + '..', 'fixtures', 'mcp-tool-heavy'); + const servers = await readActiveMcpServers(fixturePath); + const heavy = servers.find(s => s.name === 'heavy'); + assert.ok(heavy, 'expected heavy server from fixture'); + assert.equal(heavy.toolCount, 20, `expected toolCount=20, got ${heavy.toolCount}`); + assert.equal(heavy.toolCountUnknown, false); + }); + + it('falls back to toolCount: null + toolCountUnknown: true when manifest missing', async () => { + const fixturePath = resolve(import.meta.dirname || dirname(new URL(import.meta.url).pathname), + '..', 'fixtures', 'mcp-tool-heavy'); + const servers = await readActiveMcpServers(fixturePath); + const light = servers.find(s => s.name === 'light'); + assert.ok(light, 'expected light server from fixture'); + assert.equal(light.toolCount, null); + assert.equal(light.toolCountUnknown, true); + }); + + it('detects toolCount from cache file in $HOME/.claude/config-audit/mcp-cache/', async () => { + const fakeHome = uniqueDir('mcp-cache'); + const repoRoot = uniqueDir('mcp-cache-repo'); + await mkdir(repoRoot, { recursive: true }); + await writeFile( + join(repoRoot, '.mcp.json'), + JSON.stringify({ mcpServers: { cached: { command: 'npx', args: ['unknown-pkg'] } } }, null, 2), + ); + await mkdir(join(fakeHome, '.claude', 'config-audit', 'mcp-cache'), { recursive: true }); + await writeFile( + join(fakeHome, '.claude', 'config-audit', 'mcp-cache', 'cached.json'), + JSON.stringify({ tools: Array.from({ length: 12 }, (_, i) => ({ name: `t${i}` })) }, null, 2), + ); + const originalHome = process.env.HOME; + process.env.HOME = fakeHome; + try { + const servers = await readActiveMcpServers(repoRoot); + const cached = servers.find(s => s.name === 'cached'); + assert.ok(cached, 'expected cached server'); + assert.equal(cached.toolCount, 12, `expected toolCount=12 from cache, got ${cached.toolCount}`); + assert.equal(cached.toolCountUnknown, false); + } finally { + process.env.HOME = originalHome; + await rm(fakeHome, { recursive: true, force: true }); + await rm(repoRoot, { recursive: true, force: true }); + } + }); + + it('toolCount drives estimateTokens (heavy > light)', async () => { + const fixturePath = resolve(import.meta.dirname || dirname(new URL(import.meta.url).pathname), + '..', 'fixtures', 'mcp-tool-heavy'); + const servers = await readActiveMcpServers(fixturePath); + const heavy = servers.find(s => s.name === 'heavy'); + const light = servers.find(s => s.name === 'light'); + assert.ok(heavy.estimatedTokens > light.estimatedTokens, + `expected heavy (${heavy.estimatedTokens}) > light (${light.estimatedTokens})`); + }); +}); + // ───────────────────────────────────────────────────────────────────────── // readActiveConfig (integration) // ─────────────────────────────────────────────────────────────────────────