Skip to content
93 changes: 79 additions & 14 deletions src/context-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +38,15 @@ async function readConfigFile(path: string): Promise<Record<string, unknown> | n
try { return JSON.parse(raw) } catch { return null }
}

async function countMcpTools(projectPath?: string): Promise<number> {
type McpCount = {
toolCount: number
declared: number
used: number
unused: string[]
unusedTokens: number
}

async function countMcpTools(projectPath?: string, calledServers?: Set<string>): Promise<McpCount> {
const home = homedir()
const configPaths = [
join(home, '.claude', 'settings.json'),
Expand All @@ -42,21 +58,59 @@ async function countMcpTools(projectPath?: string): Promise<number> {
configPaths.push(join(projectPath, '.claude', 'settings.local.json'))
}

const servers = new Set<string>()
let toolCount = 0
const normalizedSeen = new Set<string>()
const serverNames: string[] = []

const pushServers = (serversObj: Record<string, unknown>): 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<string, unknown>
for (const name of Object.keys(mcpServers)) {
if (servers.has(name)) continue
servers.add(name)
toolCount += 5
pushServers((config.mcpServers ?? {}) as Record<string, unknown>)
}

// `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<string, unknown>)
if (projectPath) {
const projects = (claudeJson.projects ?? {}) as Record<string, { mcpServers?: Record<string, unknown> }>
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<number> {
Expand Down Expand Up @@ -101,19 +155,30 @@ async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: stri
return files
}

export async function estimateContextBudget(projectPath?: string, modelContext = 1_000_000): Promise<ContextBudget> {
const mcpToolCount = await countMcpTools(projectPath)
export async function estimateContextBudget(
projectPath?: string,
modelContext = 1_000_000,
calledServers?: Set<string>,
): Promise<ContextBudget> {
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,
Expand Down
10 changes: 8 additions & 2 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
<Text>{String(project.sessions.length).padStart(6)}</Text>
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
{hasBudgets && <Text color={budget?.mcpTools.unused.length ? ORANGE : '#7B9EF5'}>{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
</Text>
)
})}
Expand Down Expand Up @@ -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<string>()
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)
}
Expand Down
44 changes: 35 additions & 9 deletions src/optimize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,27 +345,53 @@ export function loadMcpConfigs(projectCwds: Iterable<string>): Map<string, McpCo
join(homedir(), '.claude', 'settings.json'),
join(homedir(), '.claude', 'settings.local.json'),
]
const projectCwdList: string[] = []
for (const cwd of projectCwds) {
projectCwdList.push(cwd)
configPaths.push(join(cwd, '.mcp.json'))
configPaths.push(join(cwd, '.claude', 'settings.json'))
configPaths.push(join(cwd, '.claude', 'settings.local.json'))
}

const pushServers = (serversObj: Record<string, unknown>, 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<string, unknown>
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<string, unknown>, 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<string, unknown>, mtime)
const projectsObj = (config.projects ?? {}) as Record<string, { mcpServers?: Record<string, unknown> }>
for (const cwd of projectCwdList) {
const entry = projectsObj[cwd] ?? projectsObj[cwd.replace(/\\/g, '/')]
if (entry?.mcpServers) {
pushServers(entry.mcpServers, mtime)
}
}
}
}

return servers
}

Expand Down Expand Up @@ -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: {
Expand Down
Loading