diff --git a/src/context-budget.ts b/src/context-budget.ts index b5c72d6..6dc7f94 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,59 @@ 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[] = [] + + 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) + serverNames.push(name) + } + } 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 + 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 + + 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 +155,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, diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 21281f8..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)}} ) })} @@ -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) } diff --git a/src/optimize.ts b/src/optimize.ts index 2e8913c..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 } @@ -511,12 +537,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 e43f66b..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', () => { @@ -209,6 +265,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/) + }) }) // ============================================================================ @@ -400,3 +469,114 @@ 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([]) + }) +}) + +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) + }) +})