This commit is contained in:
Ninym 2026-05-12 23:27:37 +09:00 committed by GitHub
commit 4417e08d14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 319 additions and 26 deletions

View file

@ -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<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'),
@ -42,21 +58,63 @@ 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)
}
}
}
return toolCount
const toolCount = normalizedSeen.size * TOOLS_PER_MCP_SERVER
const declared = normalizedSeen.size
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[] = []
for (const name of serverNames) {
const normalized = name.replace(/:/g, '_')
if (calledServers.has(normalized)) {
usedCount++
} else {
unused.push(name)
}
}
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> {
@ -101,19 +159,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,

View file

@ -290,7 +290,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>
)
})}
@ -755,7 +755,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)
}

View file

@ -397,19 +397,15 @@ 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'))
}
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>
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)
@ -418,6 +414,36 @@ export function loadMcpConfigs(projectCwds: Iterable<string>): Map<string, McpCo
}
}
}
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 {}
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
}
@ -901,12 +927,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: {

View file

@ -49,6 +49,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)
@ -170,6 +174,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', () => {
@ -210,6 +266,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/)
})
})
// ============================================================================
@ -435,3 +504,126 @@ 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('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({
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)
})
})