Merge pull request #991 from avs-io/codex/issue-975-connector-guidance

fix(optimize): separate connector and local MCP guidance
This commit is contained in:
Resham Joshi 2026-08-18 08:44:32 -07:00 committed by GitHub
commit 05eeab2fce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1195 additions and 63 deletions

View file

@ -28,6 +28,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
- **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991)
- **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged.
- **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs.
- **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo.

View file

@ -403,7 +403,7 @@ export type SpendFlow = {
// ————— src/optimize.ts —————
export type WasteAction =
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' }
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' | 'manual' }
| { type: 'command'; label: string; text: string }
| { type: 'file-content'; label: string; path: string; content: string }

View file

@ -208,6 +208,32 @@ describe('Optimize', () => {
expect(screen.getByText('{"batch":true}')).toBeInTheDocument()
})
it('renders and copies connector guidance as a manual action', async () => {
const report = makeOptimizeReport()
report.findings.push({
id: 'mcp-low-coverage', title: 'Underused claude.ai connector',
explanation: 'The connector loads unused tools.', severity: 'medium',
trend: null, tokensSaved: 2_000, estimatedSavingsUSD: 1,
// Connector-only: no appliable plan, so the finding is a nudge.
class: 'nudge', basis: 'estimated',
fix: {
type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:',
text: 'Open /mcp and disable claude.ai Google Calendar.',
},
})
report.summary.byClass.nudge = { tokensSaved: 19_400, savingsUSD: 9.7, count: 2 }
getOptimizeReport.mockResolvedValue(report)
render(<Optimize period="30days" provider="all" />)
const row = await screen.findByRole('button', { name: /Underused claude.ai connector/ })
fireEvent.click(row)
expect(screen.getByText('Manage the connector where it loads:')).toBeInTheDocument()
expect(screen.getByText('Open /mcp and disable claude.ai Google Calendar.')).toBeInTheDocument()
expect(row.parentElement?.querySelector('.opt-fix')).toHaveClass('opt-fix-paste')
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('Open /mcp and disable claude.ai Google Calendar.'))
})
it('switches to Reverts and Abandoned and shows only the matching yield details', async () => {
render(<Optimize period="30days" provider="all" />)
await screen.findByText('Opus is doing your small talk')

View file

@ -42,16 +42,47 @@ function changeLines(fp: FindingPlan): string[] {
})
}
function planTokensSaved(fp: FindingPlan): number {
if (fp.plan?.mcpSavingsUncertain) return Number.NaN
const byServer = fp.finding.applyTokensSavedByServer
const affected = fp.plan?.affectedMcpServers
if (byServer && affected) return affected.reduce((sum, server) => sum + (byServer[server] ?? 0), 0)
return fp.finding.applyTokensSaved ?? fp.finding.tokensSaved
}
function manualActionLines(fp: FindingPlan): string[] {
if (fp.finding.manualFollowUp) {
return [fp.finding.manualFollowUp.label, fp.finding.manualFollowUp.text]
}
const action = fp.finding.fix
if (action.type === 'paste' && action.destination === 'manual') {
return [action.label, action.text]
}
return []
}
export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], costRate: number): string {
const lines: string[] = ['']
lines.push(chalk.bold(' Appliable config-class fixes:'))
appliable.forEach((fp, i) => {
const f = fp.finding
const savings = `~${formatTokens(f.tokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(f.tokensSaved * costRate)}` : ''}`
const actionTokensSaved = planTokensSaved(fp)
const savings = Number.isFinite(actionTokensSaved)
? `~${formatTokens(actionTokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(actionTokensSaved * costRate)}` : ''}`
: 'Savings not estimated'
lines.push('')
lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`)
if (fp.plan?.affectedMcpServers?.length) {
const servers = fp.plan.affectedMcpServers.join(', ')
lines.push(chalk.yellow(` Removes local MCP server${fp.plan.affectedMcpServers.length === 1 ? '' : 's'}: ${servers}`))
}
for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`))
for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`))
const manualLines = manualActionLines(fp)
if (manualLines.length > 0) {
lines.push(chalk.cyan(' Manual follow-up (not applied):'))
for (const line of manualLines) lines.push(chalk.cyan(` ${line}`))
}
})
if (manual.length > 0) {
lines.push('')
@ -59,6 +90,7 @@ export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[],
for (const fp of manual) {
lines.push(chalk.dim(` - ${fp.finding.title} [${fp.finding.id}] manual`))
for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`))
for (const line of manualActionLines(fp)) lines.push(chalk.cyan(` ${line}`))
}
}
lines.push('')
@ -134,6 +166,7 @@ export async function runOptimizeApply(
print(chalk.dim('\n No appliable config-class fixes for this period.'))
for (const fp of manual) {
for (const note of fp.notes) print(chalk.yellow(` ! ${fp.finding.id}: ${note}`))
for (const line of manualActionLines(fp)) print(chalk.cyan(` ${line}`))
}
print()
return
@ -184,6 +217,11 @@ export async function runOptimizeApply(
applied++
print(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`)
print(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`))
const manualLines = manualActionLines(fp)
if (manualLines.length > 0) {
print(chalk.cyan(' Still requires manual action:'))
for (const line of manualLines) print(chalk.cyan(` ${line}`))
}
} catch (e) {
errout.write(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`) + '\n')
process.exitCode = 1

View file

@ -276,12 +276,15 @@ function pathNoteAdder(pathNotes: Record<string, string>): (path: string, note:
}
function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : []
const servers = finding.apply?.kind === 'mcp-remove'
? [...new Set(finding.apply.servers)]
: []
const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson]
const docs = new ConfigDocs(r.homeDir)
const skips: string[] = []
const pathNotes: Record<string, string> = {}
const addPathNote = pathNoteAdder(pathNotes)
const affectedServers: string[] = []
for (const server of servers) {
let removed = false
@ -292,14 +295,24 @@ function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
if (res.removed) removed = true
if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir))
}
if (!removed) skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`)
if (removed) affectedServers.push(server)
else skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`)
}
const changes = docs.changes()
const notes = [...docs.errorNotes(), ...skips]
const attribution = finding.applyTokensSavedByServer
const partialWithoutAttribution = affectedServers.length < servers.length && !attribution
const affectedMissingAttribution = attribution !== undefined
&& affectedServers.some(server => !Object.hasOwn(attribution, server))
const savingsUncertain = docs.errorNotes().length > 0
|| partialWithoutAttribution
|| affectedMissingAttribution
if (changes.length === 0) return { plan: null, notes }
const plan = mcpPlan('mcp-remove', finding.id, `Remove ${affectedServers.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes, affectedServers)
if (savingsUncertain) plan.mcpSavingsUncertain = true
return {
plan: mcpPlan('mcp-remove', finding.id, `Remove ${changes.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes),
plan,
notes,
...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}),
}
@ -372,8 +385,8 @@ function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPla
}
}
function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[]): ActionPlan {
return { kind, findingId, description, changes }
function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[], affectedMcpServers?: string[]): ActionPlan {
return { kind, findingId, description, changes, ...(affectedMcpServers ? { affectedMcpServers } : {}) }
}
// ---------------------------------------------------------------------------

View file

@ -731,7 +731,8 @@ type CaptureCtx = {
now: Date
}
function mcpServersFromApply(finding: WasteFinding): string[] {
function mcpServersFromApply(finding: WasteFinding, affectedMcpServers?: string[]): string[] {
if (affectedMcpServers) return affectedMcpServers
if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers
if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server)
return []
@ -749,24 +750,37 @@ function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] {
return observedMcpServers(ctx.projects)
}
export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
export function captureBaseline(
finding: WasteFinding,
kind: ActionKind,
ctx: CaptureCtx,
affectedMcpServers?: string[],
): ActionBaseline | undefined {
const common = {
windowDays: ctx.windowDays,
capturedAt: ctx.now.toISOString(),
estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)),
estimatedTokens: Math.max(0, Math.round(finding.applyTokensSaved ?? finding.tokensSaved)),
}
if (MCP_KINDS.has(kind)) {
const servers = mcpServersFromApply(finding)
const servers = mcpServersFromApply(finding, affectedMcpServers)
if (servers.length === 0) return undefined
const covByServer = new Map(ctx.coverage.map(c => [c.server, c]))
const metrics: Record<string, number> = {}
for (const server of servers) {
const cov = covByServer.get(server)
const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
// Removal realizes only the unused schema that the low-coverage
// detector estimated. If coverage is unavailable, omit the numeric
// claim instead of inventing a five-tool baseline.
const tools = finding.id === 'mcp-low-coverage'
? cov?.unusedTools.length ?? 0
: cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
metrics[server] = tools * TOKENS_PER_MCP_TOOL
}
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
const estimatedTokens = finding.applyTokensSavedByServer
? Math.round(servers.reduce((sum, server) => sum + (finding.applyTokensSavedByServer?.[server] ?? 0), 0))
: common.estimatedTokens
return { ...common, estimatedTokens, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}
if (DEFER_KINDS.has(kind)) {
@ -815,7 +829,8 @@ export async function captureBaselinesForPlans(
const projects = await loadProjects({ start, end: now })
const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now }
for (const fp of applicable) {
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx)
if (fp.plan!.mcpSavingsUncertain) continue
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx, fp.plan!.affectedMcpServers)
if (baseline) fp.plan!.baseline = baseline
}
}

View file

@ -68,6 +68,12 @@ export type ActionPlan = {
findingId?: string | null
changes: PlannedChange[]
baseline?: ActionBaseline
// MCP plans only: exact server identities the generated file mutations own.
// Preview and baseline capture must not claim skipped/managed targets.
affectedMcpServers?: string[]
// Relevant config scopes could not all be read, so removal may proceed
// with warnings but savings/baseline claims must be suppressed.
mcpSavingsUncertain?: boolean
}
// Applied actions are re-measured on every `codeburn optimize` run: only fixes

View file

@ -1047,6 +1047,8 @@ function actionDestinationHeader(action: WasteAction): string {
return '── Ask Claude in the current session '.padEnd(64, '─')
case 'shell-config':
return '── Add to your shell config '.padEnd(64, '─')
case 'manual':
return '── Manual action '.padEnd(64, '─')
default:
return '── Suggested action '.padEnd(64, '─')
}

View file

@ -246,6 +246,7 @@ export type PasteDestination =
| 'session-opener' // one-time paste at the start of a NEW session
| 'prompt' // one-time ask in the current Claude conversation
| 'shell-config' // append to ~/.zshrc / ~/.bashrc
| 'manual' // instructions the user carries out directly
export type WasteAction =
| { type: 'paste'; label: string; text: string; destination?: PasteDestination }
@ -402,9 +403,17 @@ export function classTotals(findings: WasteFinding[], costRate: number): Record<
keep: { tokensSaved: 0, savingsUSD: 0, count: 0 },
}
for (const f of findings) {
const t = totals[findingClass(f)]
t.tokensSaved += f.tokensSaved
t.savingsUSD += f.tokensSaved * costRate
const cls = findingClass(f)
// A `fix` whose plan owns only part of its estimate (a mixed local +
// claude.ai connector MCP finding) contributes only the apply-able
// subset, so this subtotal and the "apply-able" headline never promise
// what `--apply` cannot recover. The finding keeps the whole
// opportunity in its own `tokensSaved`, so the fix subtotal can be
// smaller than the findings listed under it.
const tokens = cls === 'fix' ? f.applyTokensSaved ?? f.tokensSaved : f.tokensSaved
const t = totals[cls]
t.tokensSaved += tokens
t.savingsUSD += tokens * costRate
t.count++
}
return totals
@ -454,6 +463,18 @@ export type WasteFinding = {
explanation: string
impact: Impact
tokensSaved: number
/// Savings attributable to the automatic mutation when it covers only a
/// subset of the finding. Omitted when `tokensSaved` already describes the
/// whole apply action (or when the finding is manual-only).
applyTokensSaved?: number
/// Per-server shares from the same capped cost pass as `tokensSaved`.
/// Internal apply/report consumers use this to price only targets that a
/// concrete mutation plan can actually edit; JSON output remains stable.
applyTokensSavedByServer?: Record<string, number>
/// Additional by-hand action retained when `fix` is an executable local
/// command (for example, connector guidance beside a local MCP removal).
/// Internal apply UI metadata; the stable optimize JSON mapper omits it.
manualFollowUp?: { label: string; text: string }
fix: WasteAction
trend?: Trend
apply?: FindingApply
@ -902,6 +923,27 @@ export function loadMcpConfigs(projectCwds: Iterable<string>, homeDir = homedir(
return servers
}
/// Server names owned by readable local MCP config, normalized the way
/// transcript namespaces are (":" -> "_"). `loadMcpConfigs` covers
/// settings.json and .mcp.json; `~/.claude.json` adds the top-level and
/// per-project `mcpServers` containers the remove plan also edits.
///
/// A `claude_ai_*` namespace listed here is a local server that happens to
/// carry the connector prefix, not a claude.ai connector. Config we cannot
/// read simply contributes no names, which leaves those namespaces on the
/// conservative connector path.
export function localMcpServerNames(projectCwds: Iterable<string>, homeDir = homedir()): Set<string> {
const names = new Set(loadMcpConfigs(projectCwds, homeDir).keys())
const userJson = readJsonFile(join(homeDir, '.claude.json'))
const projects = (userJson?.['projects'] ?? {}) as Record<string, { mcpServers?: unknown } | null>
const containers = [userJson?.['mcpServers'], ...Object.values(projects).map(entry => entry?.mcpServers)]
for (const container of containers) {
if (!container || typeof container !== 'object') continue
for (const name of Object.keys(container)) names.add(name.replace(/:/g, '_'))
}
return names
}
// ============================================================================
// Detectors
// ============================================================================
@ -1041,6 +1083,12 @@ type McpSchemaCostEstimate = {
effectiveInputTokens: number
}
type McpSchemaCostAttribution = McpSchemaCostEstimate & {
byServer: Record<string, McpSchemaCostEstimate>
}
type McpUnusedToolsByServer = Record<string, number | readonly string[]>
/**
* Aggregate MCP inventory and invocations across the projects in scope.
*
@ -1216,49 +1264,86 @@ export function estimateMcpSchemaCost(
counts = unusedToolCounts
}
const totalUnusedSchemaTokens = servers.reduce(
(s, srv) => s + (counts[srv] ?? 0) * TOKENS_PER_MCP_TOOL,
0,
)
if (totalUnusedSchemaTokens === 0) {
return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 }
const attributed = estimateMcpSchemaCostAttributed(counts, projects, servers)
return {
cacheWriteTokens: attributed.cacheWriteTokens,
cacheReadTokens: attributed.cacheReadTokens,
effectiveInputTokens: attributed.effectiveInputTokens,
}
}
function estimateMcpSchemaCostAttributed(
unusedToolsByServer: McpUnusedToolsByServer,
projects: ProjectSummary[],
servers: string[],
): McpSchemaCostAttribution {
servers = [...new Set(servers)]
const byServer: Record<string, McpSchemaCostEstimate> = {}
for (const server of servers) {
byServer[server] = { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 }
}
const serverSet = new Set(servers)
let cacheWriteTokens = 0
let cacheReadTokens = 0
const addBucket = (
loaded: Array<{ server: string; schemaTokens: number }>,
bucket: number,
key: 'cacheWriteTokens' | 'cacheReadTokens',
): void => {
if (bucket <= 0) return
const totalSchemaTokens = loaded.reduce((sum, entry) => sum + entry.schemaTokens, 0)
if (totalSchemaTokens <= 0) return
const charged = Math.min(totalSchemaTokens, bucket)
for (const entry of loaded) {
byServer[entry.server]![key] += charged * (entry.schemaTokens / totalSchemaTokens)
}
}
for (const project of projects) {
for (const session of project.sessions) {
// A session counts only if its observed inventory included at least
// one of the flagged servers — same invariant `aggregateMcpCoverage`
// uses for `loadedSessions`.
let loaded = false
for (const fqn of session.mcpInventory ?? []) {
const seg = fqn.split('__')[1]
if (seg && serverSet.has(seg)) { loaded = true; break }
const inventory = new Set(session.mcpInventory ?? [])
const inventoryCounts = new Map<string, number>()
for (const fqn of inventory) {
const parts = fqn.split('__')
if (parts[0] !== 'mcp' || !parts[1] || parts.length < 3) continue
inventoryCounts.set(parts[1], (inventoryCounts.get(parts[1]) ?? 0) + 1)
}
if (!loaded) continue
const loaded: Array<{ server: string; schemaTokens: number }> = []
for (const server of servers) {
const unused = unusedToolsByServer[server]
const toolCount = typeof unused === 'number'
? Math.min(unused, inventoryCounts.get(server) ?? 0)
: [...new Set(unused ?? [])].reduce((count, fqn) => count + (inventory.has(fqn) ? 1 : 0), 0)
if (toolCount > 0) loaded.push({ server, schemaTokens: toolCount * TOKENS_PER_MCP_TOOL })
}
if (loaded.length === 0) continue
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
// Both buckets can be non-zero on the same call (cache rebuild
// alongside a partial read), so account for them independently.
// The cap is applied to the combined unused-schema budget so
// multiple flagged servers cannot all claim the same call.
if (call.usage.cacheCreationInputTokens > 0) {
cacheWriteTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheCreationInputTokens)
}
if (call.usage.cacheReadInputTokens > 0) {
cacheReadTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheReadInputTokens)
}
// A cache bucket is shared by every flagged schema loaded on this
// call. Charge it once, then attribute the capped amount in
// proportion to each server's unused schema. This conserves the
// combined total and makes any local-only subset additive.
addBucket(loaded, call.usage.cacheCreationInputTokens, 'cacheWriteTokens')
addBucket(loaded, call.usage.cacheReadInputTokens, 'cacheReadTokens')
}
}
}
}
const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT
return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens }
let cacheWriteTokens = 0
let cacheReadTokens = 0
for (const estimate of Object.values(byServer)) {
estimate.effectiveInputTokens = estimate.cacheWriteTokens * CACHE_WRITE_MULTIPLIER
+ estimate.cacheReadTokens * CACHE_READ_DISCOUNT
cacheWriteTokens += estimate.cacheWriteTokens
cacheReadTokens += estimate.cacheReadTokens
}
return {
cacheWriteTokens,
cacheReadTokens,
effectiveInputTokens: cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT,
byServer,
}
}
/**
@ -1278,6 +1363,7 @@ export function estimateMcpSchemaCost(
export function detectMcpToolCoverage(
projects: ProjectSummary[],
coverage = aggregateMcpCoverage(projects),
localServerNames: ReadonlySet<string> = new Set(),
): WasteFinding | null {
if (coverage.length === 0) return null
@ -1292,30 +1378,102 @@ export function detectMcpToolCoverage(
const lines: string[] = []
const removeCommands: string[] = []
const unusedCountsByServer: Record<string, number> = {}
const unusedToolsByServer: Record<string, readonly string[]> = {}
const flaggedServers: string[] = []
const localServers: string[] = []
const connectorServers: string[] = []
// Local, but named like a connector: the transcript cannot tell the two
// apart, so the removal targets the config entry and the guidance warns
// about a possible same-name connector instead of asserting one.
const ambiguousServers: string[] = []
for (const c of flagged) {
unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked
unusedToolsByServer[c.server] = c.unusedTools
flaggedServers.push(c.server)
const pct = Math.round(c.coverageRatio * 100)
lines.push(
`${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`,
)
removeCommands.push(`claude mcp remove '${c.server}'`)
if (c.server.startsWith('claude_ai_') && !localServerNames.has(c.server)) {
connectorServers.push(c.server)
} else {
if (c.server.startsWith('claude_ai_')) ambiguousServers.push(c.server)
localServers.push(c.server)
removeCommands.push(`claude mcp remove '${c.server}'`)
}
}
// Single combined cost pass: caps each call's contribution at the
// total unused-schema budget across all flagged servers, so two
// flagged servers cannot independently claim the same call's cache
// bucket and overstate `tokensSaved`.
const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers)
const cost = estimateMcpSchemaCostAttributed(unusedToolsByServer, projects, flaggedServers)
const tokensSaved = Math.round(cost.effectiveInputTokens)
const applyTokensSavedByServer = Object.fromEntries(localServers.map(server => [
server,
cost.byServer[server]?.effectiveInputTokens ?? 0,
]))
const localTokensSaved = Object.values(applyTokensSavedByServer).reduce((sum, value) => sum + value, 0)
const applyTokensSaved = localServers.length > 0 && connectorServers.length > 0
? Math.round(localTokensSaved)
: undefined
const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS
? 'high'
: flagged.length >= UNUSED_MCP_HIGH_THRESHOLD
? 'high'
: 'medium'
// `claude_ai_*` is Claude Code's transcript namespace for server-side
// claude.ai connectors, which are not local mcpServers entries, so
// `claude mcp remove` and the file-editing apply plan cannot own them --
// unless readable local config claims the exact name (`ambiguousServers`).
// Coverage is aggregate here; project-level config attribution is deliberately
// out of scope, hence the instruction to inspect /mcp per affected project.
const one = connectorServers.length === 1
const connectorLabels = connectorServers.map(server =>
`claude.ai ${server.slice('claude_ai_'.length).replaceAll('_', ' ')}`,
)
const connectorEvidence = connectorServers.map((server, index) =>
`${connectorLabels[index]} (${server})`,
)
const connectorGuidance = connectorServers.length > 0
? ` ${connectorEvidence.join(', ')} ${one ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${one ? 'it loads' : 'they load'}, or manage ${one ? 'it' : 'them'} in claude.ai Settings > Connectors.`
: ''
const oneAmbiguous = ambiguousServers.length === 1
const ambiguousNote = ambiguousServers.length > 0
? `If you also use ${oneAmbiguous ? 'a claude.ai connector' : 'claude.ai connectors'} named ${ambiguousServers.join(', ')}, manage ${oneAmbiguous ? 'it' : 'them'} with /mcp or in claude.ai Settings > Connectors.`
: ''
const ambiguousGuidance = ambiguousServers.length > 0
? ` ${ambiguousServers.join(', ')} ${oneAmbiguous ? 'is a local MCP config entry whose name matches' : 'are local MCP config entries whose names match'} the claude.ai connector namespace, so the removal below edits local config only. ${ambiguousNote}`
: ''
const connectorText = [
connectorServers.length > 0
? `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${one ? 'it' : 'them'} in claude.ai Settings > Connectors.`
: '',
ambiguousNote,
].filter(Boolean).join(' ')
const connectorAction = connectorText
? {
label: connectorServers.length === 0
? 'Check for a same-name claude.ai connector:'
: one ? 'Manage the underused claude.ai connector where it loads:'
: 'Manage the underused claude.ai connectors where they load:',
text: connectorText,
}
: undefined
const fix: WasteAction = localServers.length > 0
? {
type: 'command',
label: localServers.length === 1
? 'Remove the underused local server, or trim its tools in your MCP config:'
: 'Remove underused local servers, or trim their tools in your MCP config:',
text: removeCommands.join('\n'),
}
: {
type: 'paste',
destination: 'manual',
label: connectorAction!.label,
text: connectorAction!.text,
}
return {
id: 'mcp-low-coverage',
@ -1323,17 +1481,16 @@ export function detectMcpToolCoverage(
explanation:
`Schema for unused tools is loaded into the system prompt every session and ` +
`carried in the cached prefix on every turn. ` +
`${lines.join('; ')}.`,
`${lines.join('; ')}.${connectorGuidance}${ambiguousGuidance}`,
impact,
tokensSaved,
fix: {
type: 'command',
label: flagged.length === 1
? 'Remove the underused server, or trim its tools in your MCP config:'
: 'Remove underused servers, or trim their tools in your MCP config:',
text: removeCommands.join('\n'),
},
apply: { kind: 'mcp-remove', servers: flaggedServers },
...(applyTokensSaved !== undefined ? { applyTokensSaved } : {}),
...(localServers.length > 0 ? { applyTokensSavedByServer } : {}),
...(localServers.length > 0 && connectorAction ? { manualFollowUp: connectorAction } : {}),
fix,
...(localServers.length > 0
? { apply: { kind: 'mcp-remove' as const, servers: localServers } }
: {}),
}
}
@ -3358,7 +3515,7 @@ export async function scanAndDetect(
claudeOnly(() => detectJunkReads(toolCalls, dateRange)),
claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)),
claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)),
() => detectMcpToolCoverage(projects, mcpCoverage),
() => detectMcpToolCoverage(projects, mcpCoverage, localMcpServerNames(projectCwds)),
() => detectMcpProfileAdvisor(projects, mcpCoverage),
// mcp-deferral-gaps family (#614): detection only, no apply plans yet.
claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)),
@ -3450,6 +3607,7 @@ function renderActionHeader(action: WasteAction): string {
case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)')
case 'prompt': return fillTo('Ask Claude in the current session')
case 'shell-config': return fillTo('Add to your shell config')
case 'manual': return fillTo('Manual action')
default: return fillTo('Suggested action')
}
}

View file

@ -9,11 +9,13 @@ import {
buildActReportJson,
buildOptimizeAppliedHeader,
captureBaseline,
captureBaselinesForPlans,
computeActReport,
renderActReport,
} from '../src/act/report.js'
import { formatAppliedFix, REPORT_MIN_AGE_DAYS } from '../src/act/types.js'
import type { ActionRecord } from '../src/act/types.js'
import type { FindingPlan } from '../src/act/plans.js'
import type { WasteFinding } from '../src/optimize.js'
import type { ClassifiedTurn, ProjectSummary } from '../src/types.js'
@ -765,6 +767,173 @@ describe('defer baseline capture', () => {
})
})
describe('partial-action baseline capture', () => {
it('persists the savings attributable to the local mutation, not the full mixed finding', () => {
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '2 MCP servers with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 40_000,
applyTokensSaved: 20_000,
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" },
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
}
const sessions = sessionsAt(2, daysAgo(1), {
mcpInventory: Array.from({ length: 20 }, (_, i) => `mcp__filesystem__t${i}`),
})
const baseline = captureBaseline(finding, 'mcp-remove', {
projects: [projectOf(sessions)],
coverage: [{
server: 'filesystem',
toolsAvailable: 20,
toolsInvoked: 3,
unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__unused${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 3 / 20,
}],
windowDays: 14,
now: NOW,
})
expect(baseline).toMatchObject({
estimatedTokens: 20_000,
sessions: 2,
metrics: { filesystem: 6_800 },
})
expect(finding.tokensSaved).toBe(40_000)
})
it('prices and measures only servers owned by the concrete mutation plan', () => {
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '2 MCP servers with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 30_000,
applyTokensSaved: 30_000,
applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 },
fix: { type: 'command', label: '', text: '' },
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
}
const sessions = sessionsAt(2, daysAgo(1), {
mcpInventory: [
...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`),
...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`),
],
})
const coverage = [
{
server: 'filesystem', toolsAvailable: 20, toolsInvoked: 3,
unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`),
invocations: 3, loadedSessions: 2, coverageRatio: 3 / 20,
},
{
server: 'managed', toolsAvailable: 20, toolsInvoked: 8,
unusedTools: Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`),
invocations: 8, loadedSessions: 2, coverageRatio: 8 / 20,
},
]
const baseline = captureBaseline(finding, 'mcp-remove', {
projects: [projectOf(sessions)], coverage, windowDays: 14, now: NOW,
}, ['filesystem'])
expect(baseline).toMatchObject({
estimatedTokens: 10_000,
sessions: 2,
metrics: { filesystem: 6_800 },
})
expect(baseline!.metrics).not.toHaveProperty('managed')
})
it('does not invent a low-coverage schema baseline when coverage is unavailable', () => {
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '1 MCP server with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 10_000,
applyTokensSavedByServer: { filesystem: 10_000 },
fix: { type: 'command', label: '', text: '' },
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
}
const baseline = captureBaseline(finding, 'mcp-remove', {
projects: [projectOf(sessionsAt(2, daysAgo(1)))],
coverage: [],
windowDays: 14,
now: NOW,
}, ['filesystem'])
expect(baseline).toMatchObject({
estimatedTokens: 10_000,
metrics: { filesystem: 0 },
})
})
it('stamps a narrowed plan with only its concrete server baseline', async () => {
const finding: WasteFinding = {
id: 'mcp-low-coverage', title: '2 MCP servers', explanation: '', impact: 'medium',
tokensSaved: 30_000, applyTokensSaved: 30_000,
applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 },
fix: { type: 'command', label: '', text: '' },
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
}
const plan: FindingPlan = {
finding,
notes: [],
plan: {
kind: 'mcp-remove', description: 'Remove filesystem', changes: [],
affectedMcpServers: ['filesystem'],
},
}
const sessions = sessionsAt(2, daysAgo(1), {
mcpInventory: [
...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`),
...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`),
],
})
await captureBaselinesForPlans([plan], {
now: NOW,
loadProjects: async () => [projectOf(sessions)],
})
expect(plan.plan?.baseline).toMatchObject({
estimatedTokens: 10_000,
metrics: { filesystem: 6_800 },
})
expect(plan.plan?.baseline?.metrics).not.toHaveProperty('managed')
})
it('does not stamp a numeric baseline onto an uncertain partial mutation', async () => {
const finding: WasteFinding = {
id: 'mcp-low-coverage', title: '1 MCP server', explanation: '', impact: 'medium',
tokensSaved: 10_000, applyTokensSavedByServer: { filesystem: 10_000 },
fix: { type: 'command', label: '', text: '' },
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
}
const plan: FindingPlan = {
finding,
notes: ['could not parse .mcp.json'],
plan: {
kind: 'mcp-remove', description: 'Remove filesystem', changes: [],
affectedMcpServers: ['filesystem'], mcpSavingsUncertain: true,
},
}
await captureBaselinesForPlans([plan], {
now: NOW,
loadProjects: async () => [projectOf(sessionsAt(2, daysAgo(1)))],
})
expect(plan.plan?.baseline).toBeUndefined()
})
})
describe('applied-fix verdicts', () => {
const fixOf = async (records: ActionRecord[], projects: ProjectSummary[]) => {
const actionsDir = await writeJournal(records)

View file

@ -395,6 +395,47 @@ describe('interactive terminal rendering', () => {
expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true })
})
it('labels claude.ai connector remediation as a manual action', async () => {
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
stdin.isTTY = true
stdin.setRawMode = () => stdin
stdin.ref = () => stdin
stdin.unref = () => stdin
stdout.isTTY = true
stdout.columns = 120
stdout.rows = 50
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Google_Calendar__t${i}`)
const sessions = ['connector-a', 'connector-b'].map((id, index) => {
const session = makeSession(id, 91.337 + index)
session.mcpInventory = inventory
return session
})
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [makeProject('connector-manual-action', sessions)],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: 120,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()
stdin.write('o')
let frame = ''
for (let i = 0; i < 100 && !frame.includes('Manual action'); i++) {
await new Promise(resolve => setTimeout(resolve, 10))
frame = frames.filter(value => value.trim()).at(-1) ?? ''
}
expect(frame).toContain('Manual action')
expect(frame).toContain('claude.ai Google Calendar')
expect(frame).not.toContain('Ask Claude in the current session')
})
it('leaves resize frame synchronization entirely to Ink', () => {
const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8')
expect(source).not.toContain('process.stdout.write(BSU)')

View file

@ -1,10 +1,14 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import {
aggregateMcpCoverage,
buildOptimizeJsonReport,
classTotals,
findingClass,
detectMcpProfileAdvisor,
detectMcpToolCoverage,
estimateMcpSchemaCost,
runOptimize,
} from '../src/optimize.js'
import type {
ClassifiedTurn,
@ -313,6 +317,23 @@ describe('estimateMcpSchemaCost', () => {
expect(cost.cacheWriteTokens).toBe(24_000)
})
it('does not count a duplicated server identifier twice', () => {
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
const sessions = [makeSession({
inventory,
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
})]
const cost = estimateMcpSchemaCost(
{ svc: 20 },
[project(sessions)],
['svc', 'svc'],
)
expect(cost.cacheWriteTokens).toBe(8_000)
expect(cost.effectiveInputTokens).toBe(10_000)
})
it('still works with the single-server signature (backward compat)', () => {
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
const sessions = [makeSession({
@ -333,6 +354,174 @@ describe('detectMcpToolCoverage', () => {
expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull()
})
it('keeps claude.ai connector evidence but emits manual guidance instead of a local remove command', () => {
const server = 'claude_ai_Netlify'
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
const sessions = [
makeSession({ sessionId: 'a', inventory, turns }),
makeSession({ sessionId: 'b', inventory, turns }),
]
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).not.toBeNull()
expect(finding!.tokensSaved).toBe(20_000)
// Keep the transcript namespace as evidence, but name the connector the
// way users actually see it in /mcp and claude.ai Settings.
expect(finding!.explanation).toContain(server)
expect(finding!.explanation).toContain('claude.ai Netlify')
expect(finding!.explanation).toContain('/mcp')
expect(finding!.explanation).toContain('claude.ai Settings > Connectors')
expect(finding!.fix.type).toBe('paste')
if (finding!.fix.type === 'paste') {
expect(finding!.fix.destination).toBe('manual')
expect(finding!.fix.text).toContain('/mcp')
expect(finding!.fix.text).toContain('claude.ai Netlify')
expect(finding!.fix.text).not.toContain(server)
expect(finding!.fix.text).toContain('claude.ai Settings > Connectors')
}
expect(JSON.stringify(finding)).not.toContain('claude mcp remove')
expect(finding!.apply).toBeUndefined()
})
it('renders connector-only remediation as a manual action, never an Ask Claude prompt', async () => {
const server = 'claude_ai_Google_Calendar'
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
const projects = [project([
makeSession({ sessionId: 'a', inventory, turns }),
makeSession({ sessionId: 'b', inventory, turns }),
])]
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
try {
await runOptimize(projects, 'Test period')
const output = log.mock.calls.map(args => args.join(' ')).join('\n')
expect(output).toContain('Manual action')
expect(output).toContain('claude.ai Google Calendar')
expect(output).not.toContain('Ask Claude in the current session')
} finally {
log.mockRestore()
}
})
it('keeps the public optimize JSON envelope while marking connector guidance manual', () => {
const server = 'claude_ai_Slack'
const coverage = [{
server,
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
}]
const finding = detectMcpToolCoverage([], coverage)!
const report = buildOptimizeJsonReport([], 'Test period', {
findings: [finding],
costRate: 0,
healthScore: 90,
healthGrade: 'A',
})
expect(report.findings[0]).toMatchObject({
id: 'mcp-low-coverage',
tokensSaved: 0,
fix: {
type: 'paste',
destination: 'manual',
text: expect.stringContaining('claude.ai Slack'),
},
})
expect(report.findings[0]).not.toHaveProperty('apply')
expect(report.findings[0]).not.toHaveProperty('applyTokensSaved')
expect(report.findings[0]).not.toHaveProperty('applyTokensSavedByServer')
expect(report.findings[0]).not.toHaveProperty('manualFollowUp')
})
it('intersects globally unused tool identities with each session inventory', () => {
const server = 'filesystem'
const coverage = [{
server,
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
}]
const sessions = [5, 20].map((count, index) => makeSession({
sessionId: `s${index}`,
inventory: Array.from({ length: count }, (_, i) => `mcp__${server}__t${i}`),
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
}))
const finding = detectMcpToolCoverage([project(sessions)], coverage)
// 5*400 and 20*400, each at 1.25x cache-write pricing.
expect(finding).toMatchObject({ tokensSaved: 12_500 })
expect(finding!.applyTokensSavedByServer?.filesystem).toBe(12_500)
})
it('conserves simultaneous cache-write and cache-read buckets with fractional shares', () => {
const inventory = [
...Array.from({ length: 15 }, (_, i) => `mcp__filesystem__t${i}`),
...Array.from({ length: 11 }, (_, i) => `mcp__claude_ai_Slack__t${i}`),
]
const coverage: McpServerCoverage[] = [
{
server: 'filesystem', toolsAvailable: 15, toolsInvoked: 0,
unusedTools: inventory.slice(0, 15), invocations: 0, loadedSessions: 2, coverageRatio: 0,
},
{
server: 'claude_ai_Slack', toolsAvailable: 11, toolsInvoked: 0,
unusedTools: inventory.slice(15), invocations: 0, loadedSessions: 2, coverageRatio: 0,
},
]
// Duplicate inventory entries must not increase the schema share.
const sessionInventory = [...inventory, inventory[0]!, inventory[15]!]
const sessions = ['a', 'b'].map(sessionId => makeSession({
sessionId,
inventory: sessionInventory,
turns: [makeTurn([makeCall({ cacheCreation: 5_001, cacheRead: 3_333 })])],
}))
const finding = detectMcpToolCoverage([project(sessions)], coverage)!
const total = 2 * (5_001 * 1.25 + 3_333 * 0.10)
const local = total * (15 / 26)
expect(finding.tokensSaved).toBe(Math.round(total))
expect(finding.applyTokensSaved).toBe(Math.round(local))
expect(finding.applyTokensSavedByServer?.filesystem).toBeCloseTo(local, 8)
})
it('pluralises manual guidance when only claude.ai connectors are flagged', () => {
const coverage = ['claude_ai_Slack', 'claude_ai_Google_Calendar'].map(server => ({
server,
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
}))
const finding = detectMcpToolCoverage([], coverage)
expect(finding).not.toBeNull()
expect(finding!.fix).toMatchObject({
type: 'paste',
destination: 'manual',
label: 'Manage the underused claude.ai connectors where they load:',
})
if (finding!.fix.type === 'paste') {
expect(finding!.fix.text).toContain('manage them in claude.ai Settings > Connectors')
}
expect(finding!.apply).toBeUndefined()
})
it('does not flag a server with healthy coverage', () => {
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
const turns = [makeTurn(
@ -379,9 +568,93 @@ describe('detectMcpToolCoverage', () => {
expect(finding!.explanation).toContain('1/30')
expect(finding!.fix.type).toBe('command')
expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'")
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['hf'] })
expect(finding!.tokensSaved).toBeGreaterThan(0)
})
it('keeps mixed connector guidance visible while making only the local server executable', () => {
const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server =>
Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
)
const sessions: SessionSummary[] = [
makeSession({ sessionId: 'mixed-a', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }),
makeSession({ sessionId: 'mixed-b', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }),
]
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).not.toBeNull()
// The finding describes both opportunities: 40 unused tool schemas across
// two sessions = 40K effective tokens. The automatic mutation owns only
// the 20 local schemas = 20K; the connector portion remains manual.
expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 })
expect(finding!.explanation).toContain('claude_ai_Slack')
expect(finding!.explanation).toContain('/mcp')
expect(finding!.explanation).toContain('claude.ai Settings > Connectors')
expect(finding!.fix).toEqual({
type: 'command',
label: 'Remove the underused local server, or trim its tools in your MCP config:',
text: "claude mcp remove 'filesystem'",
})
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] })
})
it('attributes a capped mixed cache bucket proportionally to the local action', () => {
const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server =>
Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
)
const sessions = ['a', 'b'].map(sessionId => makeSession({
sessionId,
inventory,
turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])],
}))
const finding = detectMcpToolCoverage([project(sessions)])
// Each call's 10K cache bucket is shared evenly by two 8K schemas.
// Total: 2 * 10K * 1.25 = 25K. The local mutation owns half.
expect(finding).toMatchObject({ tokensSaved: 25_000, applyTokensSaved: 12_500 })
})
it('charges only the flagged servers actually loaded in each session', () => {
const sessions = ['filesystem', 'claude_ai_Slack'].flatMap(server =>
['a', 'b'].map(suffix => makeSession({
sessionId: `${server}-${suffix}`,
inventory: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
})),
)
const finding = detectMcpToolCoverage([project(sessions)])
// Four sessions each load one 8K schema. The combined finding must not
// charge both schemas to every session merely because both are flagged.
expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 })
})
it('disambiguates a claude.ai connector from a similarly named local server', () => {
const sessions: SessionSummary[] = []
for (const server of ['claude_ai_Netlify', 'netlify']) {
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
sessions.push(
makeSession({ sessionId: `${server}-a`, inventory }),
makeSession({ sessionId: `${server}-b`, inventory }),
)
}
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).not.toBeNull()
expect(finding!.explanation).toContain('claude_ai_Netlify')
expect(finding!.explanation).toContain('separate from any similarly named local MCP server')
expect(finding!.fix.type).toBe('command')
if (finding!.fix.type === 'command') {
expect(finding!.fix.text).toBe("claude mcp remove 'netlify'")
expect(finding!.fix.text).not.toContain('claude_ai_Netlify')
}
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['netlify'] })
})
it('escalates impact to high when token waste crosses the threshold', () => {
const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`)
// 60 tools * 400 tokens = 24k schema. With many sessions and large
@ -654,3 +927,128 @@ describe('detectMcpProfileAdvisor', () => {
expect(detectMcpProfileAdvisor(projects, coverage)).toBeNull()
})
})
// ---------------------------------------------------------------------------
// Connector findings under the fix/nudge/keep classification (#1019)
// ---------------------------------------------------------------------------
describe('connector findings and finding class', () => {
const inventoryFor = (servers: string[]) => servers.flatMap(server =>
Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
)
const twoSessions = (servers: string[]) => ['a', 'b'].map(sessionId => makeSession({
sessionId,
inventory: inventoryFor(servers),
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
}))
it('classifies a connector-only finding as a nudge, since nothing is appliable', () => {
const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_Gmail']))])
expect(finding).not.toBeNull()
expect(finding!.apply).toBeUndefined()
expect(findingClass(finding!)).toBe('nudge')
expect(finding!.tokensSaved).toBeGreaterThan(0)
// Never lands in the "apply-able" subtotal.
expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 0, savingsUSD: 0, count: 0 })
})
it('counts only the local subset of a mixed finding towards the apply-able subtotal', () => {
const finding = detectMcpToolCoverage([project(twoSessions(['filesystem', 'claude_ai_Slack']))])
expect(finding).not.toBeNull()
expect(findingClass(finding!)).toBe('fix')
expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 })
expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 20_000, savingsUSD: 0.4, count: 1 })
})
it("leaves a local-only finding's subtotal at its full estimate", () => {
const finding = detectMcpToolCoverage([project(twoSessions(['filesystem']))])
expect(finding).not.toBeNull()
expect(finding!.applyTokensSaved).toBeUndefined()
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved)
})
it('charges each session only for the local schemas it actually loaded', () => {
// Same per-session scoping the connector split relies on, with no
// connector in play: two flagged local servers in disjoint sessions are
// charged one schema each, not both schemas everywhere.
const sessions = ['filesystem', 'playwright'].flatMap(server =>
['a', 'b'].map(suffix => makeSession({
sessionId: `${server}-${suffix}`,
inventory: inventoryFor([server]),
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
})),
)
const finding = detectMcpToolCoverage([project(sessions)])
expect(finding).toMatchObject({ tokensSaved: 40_000 })
expect(finding!.applyTokensSaved).toBeUndefined()
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000)
})
it('treats a claude_ai_* name owned by local config as a local server', () => {
const finding = detectMcpToolCoverage(
[project(twoSessions(['claude_ai_homegrown']))],
undefined,
new Set(['claude_ai_homegrown']),
)
expect(finding!.fix).toEqual({
type: 'command',
label: 'Remove the underused local server, or trim its tools in your MCP config:',
text: "claude mcp remove 'claude_ai_homegrown'",
})
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['claude_ai_homegrown'] })
expect(findingClass(finding!)).toBe('fix')
// Local config owns the name, so the finding must not claim it is a connector.
expect(finding!.explanation).not.toContain('is a claude.ai connector namespace')
// ...but the transcript cannot rule out a same-name connector.
expect(finding!.explanation).toContain('If you also use a claude.ai connector named claude_ai_homegrown')
expect(finding!.manualFollowUp?.label).toBe('Check for a same-name claude.ai connector:')
// The whole estimate is appliable: nothing is reserved for a connector.
expect(finding!.applyTokensSaved).toBeUndefined()
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved)
})
it('keeps a claude_ai_* name absent from local config a connector', () => {
const finding = detectMcpToolCoverage(
[project(twoSessions(['claude_ai_Gmail']))],
undefined,
new Set(['filesystem', 'playwright']),
)
expect(finding!.fix.type).toBe('paste')
expect(finding!.apply).toBeUndefined()
expect(findingClass(finding!)).toBe('nudge')
expect(finding!.explanation).toContain('is a claude.ai connector namespace')
})
it('falls back to prefix-only when no local config could be read', () => {
// Unreadable or absent config contributes no names, which leaves every
// claude_ai_* namespace on the conservative connector path.
const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_homegrown']))])
expect(finding!.fix.type).toBe('paste')
expect(finding!.apply).toBeUndefined()
expect(findingClass(finding!)).toBe('nudge')
})
it('applies the local entry and notes the connector when a name collides', () => {
const finding = detectMcpToolCoverage(
[project(twoSessions(['filesystem', 'claude_ai_Slack']))],
undefined,
new Set(['filesystem', 'claude_ai_Slack']),
)
// Both are local: the removal owns both entries and nothing is deferred.
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem', 'claude_ai_Slack'] })
expect(finding!.applyTokensSaved).toBeUndefined()
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000)
expect(finding!.explanation).not.toContain('is a claude.ai connector namespace')
expect(finding!.manualFollowUp?.text)
.toBe('If you also use a claude.ai connector named claude_ai_Slack, manage it with /mcp or in claude.ai Settings > Connectors.')
})
})

View file

@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHash } from 'node:crypto'
import { PassThrough, Writable } from 'node:stream'
import stripAnsi from 'strip-ansi'
import { planFor, planFindings, type PlanContext } from '../src/act/plans.js'
import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js'
@ -105,6 +106,169 @@ describe('mcp-remove plan', () => {
await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir })
expect(await readFile(claudeJson, 'utf-8')).toBe(original)
})
it('does not plan connector removal and removes only the local server from a mixed finding', async () => {
const coverage = (server: string): McpServerCoverage => ({
server,
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
})
const connector = coverage('claude_ai_Netlify')
const connectorOnly = detectMcpToolCoverage([], [connector])!
expect(connectorOnly.apply).toBeUndefined()
expect(planFor(connectorOnly)).toBeNull()
const fx = await makeFixture()
const claudeJson = join(fx.home, '.claude.json')
await writeFile(claudeJson, JSON.stringify({
mcpServers: {
filesystem: { command: 'filesystem' },
netlify: { command: 'local-netlify' },
},
}, null, 2) + '\n')
const mixed = detectMcpToolCoverage([], [connector, coverage('filesystem')])!
expect(mixed.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] })
const plan = planFor(mixed, { homeDir: fx.home, cwd: fx.project })
expect(plan).not.toBeNull()
await runAction(plan!, fx.actionsDir)
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({
netlify: { command: 'local-netlify' },
})
})
it('previews only the savings attributable to a mixed finding local mutation', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' } },
}, null, 2) + '\n')
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '2 MCP servers with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 80_000,
applyTokensSaved: 20_000,
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" },
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
}
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
const preview = stripAnsi(renderApplyList(plans, [], 0.000002))
expect(preview).toContain('(~20.0K tokens, ~$0.040)')
expect(preview).not.toContain('~80.0K tokens')
expect(preview).not.toContain('~$0.160')
})
it('scopes targets and savings to local servers actually present in editable config', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' } },
}, null, 2) + '\n')
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '3 MCP servers with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 60_000,
applyTokensSaved: 30_000,
applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 },
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'\nclaude mcp remove 'managed'" },
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
}
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0))
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
expect(preview).toContain('Removes local MCP server: filesystem')
expect(preview).toContain('~10.0K tokens')
expect(preview).not.toContain('~30.0K tokens')
expect(preview).toContain('skipped managed: not found in editable config')
})
it('suppresses savings for a legacy partial plan without per-server attribution', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' } },
}, null, 2) + '\n')
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '2 MCP servers with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 30_000,
applyTokensSaved: 30_000,
fix: { type: 'command', label: '', text: '' },
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
}
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0))
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true)
expect(preview).toContain('Savings not estimated')
expect(preview).not.toContain('~30.0K tokens')
})
it('deduplicates repeated removal targets before planning and pricing them', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' } },
}, null, 2) + '\n')
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '1 MCP server with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 10_000,
applyTokensSavedByServer: { filesystem: 10_000 },
fix: { type: 'command', label: '', text: '' },
apply: { kind: 'mcp-remove', servers: ['filesystem', 'filesystem'] },
}
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), [], 0))
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
expect(preview).toContain('~10.0K tokens')
expect(preview).not.toContain('~20.0K tokens')
})
it('does not claim savings when another relevant config scope is unreadable', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' } },
}, null, 2) + '\n')
await writeFile(join(fx.project, '.mcp.json'), 'not json{{{')
const finding: WasteFinding = {
id: 'mcp-low-coverage',
title: '1 MCP server with low tool coverage',
explanation: '',
impact: 'medium',
tokensSaved: 10_000,
applyTokensSavedByServer: { filesystem: 10_000 },
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" },
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
}
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0))
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true)
expect(preview).toContain('Savings not estimated')
expect(preview).toContain('could not parse')
expect(preview).not.toContain('~10.0K tokens')
})
})
describe('mcp-project-scope plan', () => {
@ -421,6 +585,80 @@ async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFind
}
describe('runOptimizeApply end-to-end', () => {
it('prints connector-only manual guidance when there is nothing to apply', async () => {
const fx = await makeFixture()
const connector: McpServerCoverage = {
server: 'claude_ai_Netlify',
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Netlify__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
}
const finding = detectMcpToolCoverage([], [connector])!
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
expect(io.stdout()).toContain('No appliable config-class fixes')
expect(io.stdout()).toContain('/mcp')
expect(io.stdout()).toContain('claude.ai Netlify')
expect(io.stdout()).not.toContain('claude mcp remove')
})
it('names the exact local removal target and preserves connector follow-up in a mixed preview', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' }, netlify: { command: 'local-netlify' } },
}, null, 2) + '\n')
const coverage = (server: string): McpServerCoverage => ({
server,
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
})
const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])!
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], dryRun: true }))
expect(io.stdout()).toContain('Removes local MCP server: filesystem')
expect(io.stdout()).toContain('/mcp')
expect(io.stdout()).toContain('claude.ai Netlify')
expect(io.stdout()).not.toContain("claude mcp remove 'claude_ai_Netlify'")
})
it('keeps mixed connector follow-up explicitly pending after applying the local fix', async () => {
const fx = await makeFixture()
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
mcpServers: { filesystem: { command: 'filesystem' } },
}, null, 2) + '\n')
const coverage = (server: string): McpServerCoverage => ({
server,
toolsAvailable: 20,
toolsInvoked: 0,
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
invocations: 0,
loadedSessions: 2,
coverageRatio: 0,
})
const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])!
const io = makeIo()
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
const out = io.stdout()
expect(out).toContain('Manual follow-up (not applied):')
expect(out).toContain('Still requires manual action:')
expect(out).toContain('claude.ai Netlify')
expect(await readRecords(fx.actionsDir)).toHaveLength(1)
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({})
})
it('--yes applies every plan and prints journal short ids with the undo hint', async () => {
const { fx, findings } = await threeFindingFixture()
const io = makeIo()

View file

@ -22,6 +22,7 @@ import {
detectBashBloat,
detectGhostCommands,
loadMcpConfigs,
localMcpServerNames,
scanJsonlFile,
scanAndDetect,
detectRecurringContext,
@ -176,6 +177,32 @@ describe('loadMcpConfigs', () => {
})
})
describe('localMcpServerNames', () => {
it('adds the ~/.claude.json top-level and per-project servers to the config names', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { fromMcpJson: {} } }))
writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), JSON.stringify({
mcpServers: { 'claude_ai_homegrown': {}, 'plugin:ctx:ctx': {} },
projects: { [projectDir]: { mcpServers: { scoped: {} } } },
}))
const names = localMcpServerNames([projectDir])
expect([...names].sort()).toEqual(['claude_ai_homegrown', 'fromMcpJson', 'plugin_ctx_ctx', 'scoped'])
})
it('contributes no names when ~/.claude.json cannot be parsed', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), '{ not valid json')
expect(localMcpServerNames([projectDir]).size).toBe(0)
})
})
describe('detectUnusedMcp', () => {
it('flags servers configured but never called', () => {
const root = makeFixtureRoot()

View file

@ -1224,9 +1224,9 @@ describe('paste-fix destination tagging (issue #277)', () => {
if (f.fix.type === 'paste') {
expect(
f.fix.destination,
`finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config`
`finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config / manual`
).toBeDefined()
expect(['claude-md', 'session-opener', 'prompt', 'shell-config'])
expect(['claude-md', 'session-opener', 'prompt', 'shell-config', 'manual'])
.toContain(f.fix.destination)
}
}