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) } }