From 95448b552fc53e3745aa912732ec6dfca8e879c3 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:01 +0200 Subject: [PATCH 1/8] test(context-budget): failing tests for per-server MCP used/unused breakdown --- tests/optimize-fs.test.ts | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index e43f66b..e40f6d8 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -400,3 +400,54 @@ describe('discoverProjectCwd', () => { expect(await discoverProjectCwd(root)).toBe('/Users/test/project') }) }) + +// ============================================================================ +// estimateContextBudget with calledServers +// ============================================================================ + +describe('estimateContextBudget with calledServers', () => { + it('reports unused servers when calledServers provided', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { used: { command: 'x' }, ghost: { command: 'y' } }, + })) + const budget = await estimateContextBudget(root, 1_000_000, new Set(['used'])) + expect(budget.mcpTools.declared).toBe(2) + expect(budget.mcpTools.used).toBe(1) + expect(budget.mcpTools.unused).toEqual(['ghost']) + expect(budget.mcpTools.unusedTokens).toBe(1 * 5 * 400) + }) + + it('reports zero unused when all called', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { a: { command: 'x' }, b: { command: 'y' } }, + })) + const budget = await estimateContextBudget(root, 1_000_000, new Set(['a', 'b'])) + expect(budget.mcpTools.unused).toEqual([]) + expect(budget.mcpTools.unusedTokens).toBe(0) + }) + + it('treats calledServers=undefined as no usage data (backward compat)', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { x: { command: 'x' } }, + })) + const budget = await estimateContextBudget(root) + expect(budget.mcpTools.declared).toBe(1) + expect(budget.mcpTools.used).toBe(0) + expect(budget.mcpTools.unused).toEqual([]) + expect(budget.mcpTools.count).toBe(5) + expect(budget.mcpTools.tokens).toBe(2000) + }) + + it('normalizes plugin:foo:bar names before comparison', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { 'plugin:context7:context7': { command: 'ctx' } }, + })) + const budget = await estimateContextBudget(root, 1_000_000, new Set(['plugin_context7_context7'])) + expect(budget.mcpTools.used).toBe(1) + expect(budget.mcpTools.unused).toEqual([]) + }) +}) From 76bf94a62edf9464ee090b862b176d0a9493ca16 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:01 +0200 Subject: [PATCH 2/8] feat(context-budget): add per-server used/unused MCP breakdown --- src/context-budget.ts | 71 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/context-budget.ts b/src/context-budget.ts index b5c72d6..02c7c76 100644 --- a/src/context-budget.ts +++ b/src/context-budget.ts @@ -9,10 +9,18 @@ const CHARS_PER_TOKEN = 4 const SYSTEM_BASE_TOKENS = 10400 const TOOL_TOKENS_OVERHEAD = 400 const SKILL_FRONTMATTER_TOKENS = 80 +const TOOLS_PER_MCP_SERVER = 5 export type ContextBudget = { systemBase: number - mcpTools: { count: number; tokens: number } + mcpTools: { + count: number + tokens: number + declared: number + used: number + unused: string[] + unusedTokens: number + } skills: { count: number; tokens: number } memory: { count: number; tokens: number; files: Array<{ name: string; tokens: number }> } total: number @@ -30,7 +38,15 @@ async function readConfigFile(path: string): Promise | n try { return JSON.parse(raw) } catch { return null } } -async function countMcpTools(projectPath?: string): Promise { +type McpCount = { + toolCount: number + declared: number + used: number + unused: string[] + unusedTokens: number +} + +async function countMcpTools(projectPath?: string, calledServers?: Set): Promise { const home = homedir() const configPaths = [ join(home, '.claude', 'settings.json'), @@ -42,21 +58,41 @@ async function countMcpTools(projectPath?: string): Promise { configPaths.push(join(projectPath, '.claude', 'settings.local.json')) } - const servers = new Set() - let toolCount = 0 + const normalizedSeen = new Set() + const serverNames: string[] = [] for (const p of configPaths) { const config = await readConfigFile(p) if (!config) continue const mcpServers = (config.mcpServers ?? {}) as Record for (const name of Object.keys(mcpServers)) { - if (servers.has(name)) continue - servers.add(name) - toolCount += 5 + const normalized = name.replace(/:/g, '_') + if (normalizedSeen.has(normalized)) continue + normalizedSeen.add(normalized) + serverNames.push(name) + } + } + + const toolCount = normalizedSeen.size * TOOLS_PER_MCP_SERVER + const declared = normalizedSeen.size + + if (!calledServers || calledServers.size === 0) { + return { toolCount, declared, used: 0, unused: [], unusedTokens: 0 } + } + + let usedCount = 0 + const unused: string[] = [] + for (const name of serverNames) { + const normalized = name.replace(/:/g, '_') + if (calledServers.has(normalized)) { + usedCount++ + } else { + unused.push(name) } } - return toolCount + const unusedTokens = unused.length * TOOLS_PER_MCP_SERVER * TOOL_TOKENS_OVERHEAD + return { toolCount, declared, used: usedCount, unused, unusedTokens } } async function countSkills(projectPath?: string): Promise { @@ -101,19 +137,30 @@ async function scanMemoryFiles(projectPath?: string): Promise { - const mcpToolCount = await countMcpTools(projectPath) +export async function estimateContextBudget( + projectPath?: string, + modelContext = 1_000_000, + calledServers?: Set, +): Promise { + const mcpCount = await countMcpTools(projectPath, calledServers) const skillCount = await countSkills(projectPath) const memoryFiles = await scanMemoryFiles(projectPath) - const mcpTokens = mcpToolCount * TOOL_TOKENS_OVERHEAD + const mcpTokens = mcpCount.toolCount * TOOL_TOKENS_OVERHEAD const skillTokens = skillCount * SKILL_FRONTMATTER_TOKENS const memoryTokens = memoryFiles.reduce((s, f) => s + f.tokens, 0) const total = SYSTEM_BASE_TOKENS + mcpTokens + skillTokens + memoryTokens return { systemBase: SYSTEM_BASE_TOKENS, - mcpTools: { count: mcpToolCount, tokens: mcpTokens }, + mcpTools: { + count: mcpCount.toolCount, + tokens: mcpTokens, + declared: mcpCount.declared, + used: mcpCount.used, + unused: mcpCount.unused, + unusedTokens: mcpCount.unusedTokens, + }, skills: { count: skillCount, tokens: skillTokens }, memory: { count: memoryFiles.length, tokens: memoryTokens, files: memoryFiles }, total, From 166378a81234b36d813bff2f9347b7c692c74086 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:01 +0200 Subject: [PATCH 3/8] feat(dashboard): pass session MCP usage to estimateContextBudget --- src/dashboard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 21281f8..06e6220 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -618,7 +618,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, if (cancelled) return const cwd = await discoverProjectCwd(join(claudeDir, project.project)) if (!cwd) continue - budgets.set(project.project, await estimateContextBudget(cwd)) + const calledServers = new Set() + for (const session of project.sessions) { + for (const server of Object.keys(session.mcpBreakdown)) { + calledServers.add(server) + } + } + budgets.set(project.project, await estimateContextBudget(cwd, 1_000_000, calledServers)) } if (!cancelled) setProjectBudgets(budgets) } From afb398b276d9ad4971d552e9f1eccec7752db5f3 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:01 +0200 Subject: [PATCH 4/8] feat(dashboard): highlight overhead column when unused MCP servers detected --- src/dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 06e6220..5f525ca 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -258,7 +258,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm {formatCost(project.totalCostUSD).padStart(8)} {avgCost.padStart(PROJECT_COL_AVG)} {String(project.sessions.length).padStart(6)} - {hasBudgets && {(budget ? formatTokens(budget.total) : '-').padStart(10)}} + {hasBudgets && {(budget ? formatTokens(budget.total) : '-').padStart(10)}} ) })} From 95e18670b13d465b75d2399ab6631cc593a18c48 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:01 +0200 Subject: [PATCH 5/8] fix(optimize): show per-session MCP overhead in detectUnusedMcp explanation --- src/optimize.ts | 6 +++--- tests/optimize-fs.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/optimize.ts b/src/optimize.ts index 2e8913c..de9c768 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -511,12 +511,12 @@ export function detectUnusedMcp( if (unused.length === 0) return null const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) - const schemaTokensPerSession = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL - const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1) + const perSessionTokens = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL + const tokensSaved = perSessionTokens * Math.max(totalSessions, 1) return { title: `${unused.length} MCP server${unused.length > 1 ? 's' : ''} configured but never used`, - explanation: `Never called in this period: ${unused.join(', ')}. Each server loads ~${TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL} tokens of tool schema into every session.`, + explanation: `Never called in this period: ${unused.join(', ')}. Estimated overhead: ~${formatTokens(perSessionTokens)} tokens/session (${formatTokens(tokensSaved)} tokens total across ${totalSessions} session${totalSessions !== 1 ? 's' : ''}).`, impact: unused.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium', tokensSaved, fix: { diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index e40f6d8..08962c1 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -209,6 +209,19 @@ describe('detectUnusedMcp', () => { ] expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull() }) + + it('explanation mentions tokens/session', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { ghost: { command: 'x' } }, + })) + touchOld(join(projectDir, '.mcp.json'), 30) + const finding = detectUnusedMcp([], [], new Set([projectDir])) + expect(finding).not.toBeNull() + expect(finding!.explanation).toMatch(/tokens\/session/) + }) }) // ============================================================================ From ccbfed8c562cfc1fe842949c4ec0bb36ad4c820c Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 16:15:27 +0200 Subject: [PATCH 6/8] feat(context-budget): also read MCP servers from ~/.claude.json Claude Code CLI (`claude mcp add ...`) writes MCP server configs to ~/.claude.json, not to settings.json. The existing settings.json-only discovery missed this common config path, making the unused-server detection effectively invisible for users who configure MCPs via the CLI. Reads both the top-level mcpServers (user scope) and the per-project projects[cwd].mcpServers block (local scope). Falls back to a slash- normalized key lookup to match Windows path variants. Scope: Claude Code only. Codex/Cursor/OpenCode MCP configs use different file formats (TOML, custom JSON) and need their own reader; tracked as follow-up. --- src/context-budget.ts | 28 ++++++++++++++---- tests/optimize-fs.test.ts | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/context-budget.ts b/src/context-budget.ts index 02c7c76..6dc7f94 100644 --- a/src/context-budget.ts +++ b/src/context-budget.ts @@ -61,11 +61,8 @@ async function countMcpTools(projectPath?: string, calledServers?: Set): const normalizedSeen = new Set() const serverNames: string[] = [] - for (const p of configPaths) { - const config = await readConfigFile(p) - if (!config) continue - const mcpServers = (config.mcpServers ?? {}) as Record - for (const name of Object.keys(mcpServers)) { + const pushServers = (serversObj: Record): void => { + for (const name of Object.keys(serversObj)) { const normalized = name.replace(/:/g, '_') if (normalizedSeen.has(normalized)) continue normalizedSeen.add(normalized) @@ -73,6 +70,27 @@ async function countMcpTools(projectPath?: string, calledServers?: Set): } } + for (const p of configPaths) { + const config = await readConfigFile(p) + if (!config) continue + pushServers((config.mcpServers ?? {}) as Record) + } + + // `claude mcp add` writes to ~/.claude.json (top-level for user-scope, + // projects[cwd].mcpServers for project-local scope). This is the common config + // path and was missed by settings.json-only discovery. + const claudeJson = await readConfigFile(join(home, '.claude.json')) + if (claudeJson) { + pushServers((claudeJson.mcpServers ?? {}) as Record) + if (projectPath) { + const projects = (claudeJson.projects ?? {}) as Record }> + const projectEntry = projects[projectPath] ?? projects[projectPath.replace(/\\/g, '/')] + if (projectEntry?.mcpServers) { + pushServers(projectEntry.mcpServers) + } + } + } + const toolCount = normalizedSeen.size * TOOLS_PER_MCP_SERVER const declared = normalizedSeen.size diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 08962c1..23b2cf7 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -464,3 +464,63 @@ describe('estimateContextBudget with calledServers', () => { expect(budget.mcpTools.unused).toEqual([]) }) }) + +describe('estimateContextBudget reading ~/.claude.json (claude mcp add flow)', () => { + const claudeJsonPath = join(FAKE_HOME_FOR_MOCK, '.claude.json') + + beforeEach(() => { + rmSync(claudeJsonPath, { force: true }) + }) + + it('reads user-scope mcpServers from ~/.claude.json top-level', async () => { + writeFile(claudeJsonPath, JSON.stringify({ + mcpServers: { docker: { command: 'x' }, vault: { command: 'y' } }, + })) + const budget = await estimateContextBudget(undefined, 1_000_000, new Set(['docker'])) + expect(budget.mcpTools.declared).toBe(2) + expect(budget.mcpTools.used).toBe(1) + expect(budget.mcpTools.unused).toEqual(['vault']) + }) + + it('reads project-scope mcpServers from ~/.claude.json projects map', async () => { + const projectDir = join(makeFixtureRoot(), 'proj') + mkdirSync(projectDir, { recursive: true }) + writeFile(claudeJsonPath, JSON.stringify({ + mcpServers: {}, + projects: { [projectDir]: { mcpServers: { local: { command: 'x' } } } }, + })) + const budget = await estimateContextBudget(projectDir, 1_000_000, new Set(['other'])) + expect(budget.mcpTools.declared).toBe(1) + expect(budget.mcpTools.unused).toEqual(['local']) + }) + + it('matches projects map key with forward slashes on Windows paths', async () => { + const projectDir = join(makeFixtureRoot(), 'proj') + mkdirSync(projectDir, { recursive: true }) + writeFile(claudeJsonPath, JSON.stringify({ + projects: { [projectDir.replace(/\\/g, '/')]: { mcpServers: { win: { command: 'x' } } } }, + })) + const budget = await estimateContextBudget(projectDir, 1_000_000, new Set(['other'])) + expect(budget.mcpTools.declared).toBe(1) + }) + + it('merges ~/.claude.json top-level with .mcp.json at project root', async () => { + const projectDir = join(makeFixtureRoot(), 'proj') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { proj_only: { command: 'x' } }, + })) + writeFile(claudeJsonPath, JSON.stringify({ + mcpServers: { user_wide: { command: 'y' } }, + })) + const budget = await estimateContextBudget(projectDir, 1_000_000, new Set(['proj_only'])) + expect(budget.mcpTools.declared).toBe(2) + expect(budget.mcpTools.used).toBe(1) + expect(budget.mcpTools.unused).toEqual(['user_wide']) + }) + + it('does nothing when ~/.claude.json is missing', async () => { + const budget = await estimateContextBudget(undefined, 1_000_000, new Set()) + expect(budget.mcpTools.declared).toBe(0) + }) +}) From 2172404dd21059ac40c666cb2eebddf9212cc371 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 16:19:24 +0200 Subject: [PATCH 7/8] feat(optimize): loadMcpConfigs also reads ~/.claude.json Mirrors the countMcpTools fix (previous commit) in the sibling loadMcpConfigs used by detectUnusedMcp. Without this, the optimize view never flagged unused MCP servers for users who configure MCPs via claude mcp add (which writes to ~/.claude.json, not settings.json). Reads top-level mcpServers for user scope and projects[cwd].mcpServers for local scope, with slash-normalized lookup for Windows paths. Also adds a file-level beforeEach that cleans stale ~/.claude.json from the mocked home directory between tests to prevent state bleed across test suites. --- src/optimize.ts | 38 +++++++++++++++++++++----- tests/optimize-fs.test.ts | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/optimize.ts b/src/optimize.ts index de9c768..aacb9a2 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -345,27 +345,53 @@ export function loadMcpConfigs(projectCwds: Iterable): Map, mtime: number): void => { + for (const name of Object.keys(serversObj)) { + const normalized = name.replace(/:/g, '_') + const existing = servers.get(normalized) + if (!existing || existing.mtime < mtime) { + servers.set(normalized, { normalized, original: name, mtime }) + } + } + } + for (const p of configPaths) { if (!existsSync(p)) continue const config = readJsonFile(p) if (!config) continue let mtime = 0 try { mtime = statSync(p).mtimeMs } catch {} - const serversObj = (config.mcpServers ?? {}) as Record - for (const name of Object.keys(serversObj)) { - const normalized = name.replace(/:/g, '_') - const existing = servers.get(normalized) - if (!existing || existing.mtime < mtime) { - servers.set(normalized, { normalized, original: name, mtime }) + pushServers((config.mcpServers ?? {}) as Record, mtime) + } + + // `claude mcp add` writes to ~/.claude.json (top-level for user-scope, + // projects[cwd].mcpServers for project-local scope). This is the common config + // path and was missed by settings.json-only discovery. + const claudeJsonPath = join(homedir(), '.claude.json') + if (existsSync(claudeJsonPath)) { + const config = readJsonFile(claudeJsonPath) + if (config) { + let mtime = 0 + try { mtime = statSync(claudeJsonPath).mtimeMs } catch {} + pushServers((config.mcpServers ?? {}) as Record, mtime) + const projectsObj = (config.projects ?? {}) as Record }> + for (const cwd of projectCwdList) { + const entry = projectsObj[cwd] ?? projectsObj[cwd.replace(/\\/g, '/')] + if (entry?.mcpServers) { + pushServers(entry.mcpServers, mtime) + } } } } + return servers } diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 23b2cf7..c519dc4 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -48,6 +48,10 @@ function writeFile(path: string, content: string): void { writeFileSync(path, content) } +beforeEach(() => { + rmSync(join(FAKE_HOME_FOR_MOCK, '.claude.json'), { force: true }) +}) + function touchOld(path: string, daysAgo: number): void { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000) utimesSync(path, past, past) @@ -169,6 +173,58 @@ describe('loadMcpConfigs', () => { expect(() => loadMcpConfigs([projectDir])).not.toThrow() expect(loadMcpConfigs([projectDir]).size).toBe(0) }) + + describe('~/.claude.json support (claude mcp add flow)', () => { + const claudeJsonPath = join(FAKE_HOME_FOR_MOCK, '.claude.json') + + beforeEach(() => { + rmSync(claudeJsonPath, { force: true }) + }) + + it('reads user-scope mcpServers from ~/.claude.json top-level', () => { + writeFile(claudeJsonPath, JSON.stringify({ + mcpServers: { docker: { command: 'x' }, vault: { command: 'y' } }, + })) + const servers = loadMcpConfigs([]) + expect(servers.has('docker')).toBe(true) + expect(servers.has('vault')).toBe(true) + }) + + it('reads project-scope mcpServers from ~/.claude.json projects map', () => { + const projectDir = join(makeFixtureRoot(), 'proj') + mkdirSync(projectDir, { recursive: true }) + writeFile(claudeJsonPath, JSON.stringify({ + projects: { [projectDir]: { mcpServers: { local: { command: 'x' } } } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('local')).toBe(true) + }) + + it('matches projects map key with forward slashes on Windows paths', () => { + const projectDir = join(makeFixtureRoot(), 'proj') + mkdirSync(projectDir, { recursive: true }) + writeFile(claudeJsonPath, JSON.stringify({ + projects: { [projectDir.replace(/\\/g, '/')]: { mcpServers: { win: { command: 'x' } } } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('win')).toBe(true) + }) + + it('merges ~/.claude.json user-scope with project .mcp.json', () => { + const projectDir = join(makeFixtureRoot(), 'proj') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { proj_only: { command: 'x' } }, + })) + writeFile(claudeJsonPath, JSON.stringify({ + mcpServers: { user_wide: { command: 'y' } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('proj_only')).toBe(true) + expect(servers.has('user_wide')).toBe(true) + expect(servers.size).toBe(2) + }) + }) }) describe('detectUnusedMcp', () => { From 3357df40e62ff3a95150d6ab20593a43025efc76 Mon Sep 17 00:00:00 2001 From: Ninym Date: Tue, 28 Apr 2026 17:44:37 +0200 Subject: [PATCH 8/8] fix(context-budget): report all servers unused when calledServers is empty set An empty Set means zero MCP calls were made in the period - all declared servers are unused. Previously this case was collapsed with calledServers=undefined (no usage data), so countMcpTools returned unused=[] instead of the full server list. Adds a test covering the empty-Set path. --- src/context-budget.ts | 6 +++++- tests/optimize-fs.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/context-budget.ts b/src/context-budget.ts index 6dc7f94..571ce73 100644 --- a/src/context-budget.ts +++ b/src/context-budget.ts @@ -94,9 +94,13 @@ async function countMcpTools(projectPath?: string, calledServers?: Set): const toolCount = normalizedSeen.size * TOOLS_PER_MCP_SERVER const declared = normalizedSeen.size - if (!calledServers || calledServers.size === 0) { + if (!calledServers) { return { toolCount, declared, used: 0, unused: [], unusedTokens: 0 } } + if (calledServers.size === 0) { + const unusedTokens = serverNames.length * TOOLS_PER_MCP_SERVER * TOOL_TOKENS_OVERHEAD + return { toolCount, declared, used: 0, unused: serverNames, unusedTokens } + } let usedCount = 0 const unused: string[] = [] diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index c519dc4..dba6a86 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -510,6 +510,18 @@ describe('estimateContextBudget with calledServers', () => { expect(budget.mcpTools.tokens).toBe(2000) }) + it('reports all servers unused when calledServers is empty set', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { alpha: { command: 'a' }, beta: { command: 'b' } }, + })) + const budget = await estimateContextBudget(root, 1_000_000, new Set()) + expect(budget.mcpTools.declared).toBe(2) + expect(budget.mcpTools.used).toBe(0) + expect(budget.mcpTools.unused).toEqual(['alpha', 'beta']) + expect(budget.mcpTools.unusedTokens).toBe(2 * 5 * 400) + }) + it('normalizes plugin:foo:bar names before comparison', async () => { const root = makeFixtureRoot() writeFile(join(root, '.mcp.json'), JSON.stringify({