feat(config-audit): MCP tool-count detection with manifest fallback (v5 M1) [skip-docs]
readActiveMcpServers now resolves tool count via:
1. In-config tools array
2. Cached tools/list at \$HOME/.claude/config-audit/mcp-cache/<name>.json
3. node_modules/<pkg>/package.json (resolved from npx <pkg>)
4. Fallback: { toolCount: null, toolCountUnknown: true }
estimateTokens uses detected toolCount (heavy server > light server).
New fixture: mcp-tool-heavy/ with mocked node_modules/mcp-heavy/package.json (20 tools).
576 → 580 tests, all green.
This commit is contained in:
parent
9a44df22ac
commit
1422daf895
3 changed files with 148 additions and 7 deletions
|
|
@ -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/<name>.json
|
||||
* 3. `tools` array in the npm package's package.json (resolved from
|
||||
* <repoPath>/node_modules/<pkg>/package.json when the command is `npx <pkg>`)
|
||||
* 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 || '';
|
||||
|
|
|
|||
6
plugins/config-audit/tests/fixtures/mcp-tool-heavy/.mcp.json
vendored
Normal file
6
plugins/config-audit/tests/fixtures/mcp-tool-heavy/.mcp.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"heavy": { "command": "npx", "args": ["mcp-heavy"] },
|
||||
"light": { "command": "npx", "args": ["mcp-light"] }
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<pkg>/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)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue