diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts
index 25394087..19a652c0 100644
--- a/app/renderer/lib/types.ts
+++ b/app/renderer/lib/types.ts
@@ -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 }
diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx
index 5219afaf..f0ef0449 100644
--- a/app/renderer/sections/Optimize.test.tsx
+++ b/app/renderer/sections/Optimize.test.tsx
@@ -160,6 +160,29 @@ 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,
+ fix: {
+ type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:',
+ text: 'Open /mcp and disable claude.ai Google Calendar.',
+ },
+ })
+ getOptimizeReport.mockResolvedValue(report)
+ render()
+ 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()
await screen.findByText('Opus is doing your small talk')
diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts
index 5b90685a..72ddfbc6 100644
--- a/src/act/optimize-apply.ts
+++ b/src/act/optimize-apply.ts
@@ -37,16 +37,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('')
@@ -54,6 +85,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('')
@@ -129,6 +161,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
@@ -177,6 +210,11 @@ export async function runOptimizeApply(
const record = await runAction(fp.plan!, opts.actionsDir)
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
diff --git a/src/act/plans.ts b/src/act/plans.ts
index b3ee4e39..7d71fe2a 100644
--- a/src/act/plans.ts
+++ b/src/act/plans.ts
@@ -275,12 +275,15 @@ function pathNoteAdder(pathNotes: Record): (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 = {}
const addPathNote = pathNoteAdder(pathNotes)
+ const affectedServers: string[] = []
for (const server of servers) {
let removed = false
@@ -291,14 +294,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 } : {}),
}
@@ -371,8 +384,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 } : {}) }
}
// ---------------------------------------------------------------------------
diff --git a/src/act/report.ts b/src/act/report.ts
index 30c88120..52b0aaab 100644
--- a/src/act/report.ts
+++ b/src/act/report.ts
@@ -666,7 +666,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 []
@@ -684,24 +685,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 = {}
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)) {
@@ -750,7 +764,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
}
}
diff --git a/src/act/types.ts b/src/act/types.ts
index 4142abe4..8aa3fbc9 100644
--- a/src/act/types.ts
+++ b/src/act/types.ts
@@ -66,4 +66,10 @@ 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
}
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index b66b41cf..4c24579a 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -1046,6 +1046,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, '─')
}
diff --git a/src/optimize.ts b/src/optimize.ts
index 63d49330..a4b84780 100644
--- a/src/optimize.ts
+++ b/src/optimize.ts
@@ -232,6 +232,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 }
@@ -300,6 +301,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
+ /// 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
@@ -789,6 +802,12 @@ type McpSchemaCostEstimate = {
effectiveInputTokens: number
}
+type McpSchemaCostAttribution = McpSchemaCostEstimate & {
+ byServer: Record
+}
+
+type McpUnusedToolsByServer = Record
+
/**
* Aggregate MCP inventory and invocations across the projects in scope.
*
@@ -964,49 +983,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,
}
+}
- const serverSet = new Set(servers)
- let cacheWriteTokens = 0
- let cacheReadTokens = 0
+function estimateMcpSchemaCostAttributed(
+ unusedToolsByServer: McpUnusedToolsByServer,
+ projects: ProjectSummary[],
+ servers: string[],
+): McpSchemaCostAttribution {
+ servers = [...new Set(servers)]
+ const byServer: Record = {}
+ for (const server of servers) {
+ byServer[server] = { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 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()
+ 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)
+ }
+
+ 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) continue
+ 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,
+ }
}
/**
@@ -1040,30 +1096,81 @@ export function detectMcpToolCoverage(
const lines: string[] = []
const removeCommands: string[] = []
- const unusedCountsByServer: Record = {}
+ const unusedToolsByServer: Record = {}
const flaggedServers: string[] = []
+ const localServers: string[] = []
+ const connectorServers: 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_')) {
+ connectorServers.push(c.server)
+ } else {
+ 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. Those connectors are not local mcpServers entries,
+ // so `claude mcp remove` and the file-editing apply plan cannot own them.
+ // Coverage is aggregate here; project-level config attribution is deliberately
+ // out of scope, hence the instruction to inspect /mcp per affected project.
+ 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(', ')} ${connectorServers.length === 1 ? '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 ${connectorServers.length === 1 ? 'it loads' : 'they load'}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`
+ : ''
+ const connectorAction = connectorServers.length > 0
+ ? {
+ label: connectorServers.length === 1
+ ? 'Manage the underused claude.ai connector where it loads:'
+ : 'Manage the underused claude.ai connectors where they load:',
+ text: `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`,
+ }
+ : 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',
@@ -1071,17 +1178,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}`,
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 } }
+ : {}),
}
}
@@ -3117,6 +3223,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')
}
}
diff --git a/tests/act-report.test.ts b/tests/act-report.test.ts
index 80834d0d..9179a60a 100644
--- a/tests/act-report.test.ts
+++ b/tests/act-report.test.ts
@@ -8,10 +8,12 @@ import {
buildActReportJson,
buildOptimizeAppliedHeader,
captureBaseline,
+ captureBaselinesForPlans,
computeActReport,
renderActReport,
} from '../src/act/report.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'
@@ -762,3 +764,170 @@ describe('defer baseline capture', () => {
expect(b).toBeUndefined()
})
})
+
+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()
+ })
+})
diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts
index 9879c8a1..d121a2cb 100644
--- a/tests/dashboard.test.ts
+++ b/tests/dashboard.test.ts
@@ -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)')
diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts
index a19ddc4d..77301bc5 100644
--- a/tests/mcp-coverage.test.ts
+++ b/tests/mcp-coverage.test.ts
@@ -1,10 +1,12 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
import {
aggregateMcpCoverage,
+ buildOptimizeJsonReport,
detectMcpProfileAdvisor,
detectMcpToolCoverage,
estimateMcpSchemaCost,
+ runOptimize,
} from '../src/optimize.js'
import type {
ClassifiedTurn,
@@ -313,6 +315,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 +352,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 +566,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
diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts
index 2b582284..4d6014c2 100644
--- a/tests/optimize-apply.test.ts
+++ b/tests/optimize-apply.test.ts
@@ -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'
@@ -102,6 +103,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', () => {
@@ -418,6 +582,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()
diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts
index bc72dd88..db14c94d 100644
--- a/tests/optimize.test.ts
+++ b/tests/optimize.test.ts
@@ -1197,9 +1197,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)
}
}