bookworm-smart-assistant/scripts/mcp-usage-tracker.js
Bookworm Admin b7a8e29d21 release: v6.7.0 - OTA E2E test release
- VERSION file as authoritative version source
- export.mjs reads VERSION with package.json fallback
- bw-ota.ps1 DryRun mode for safe testing
- auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
2026-04-27 17:59:44 +08:00

236 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* MCP 使用率追踪器 (Phase 1 · T1.1)
* sentinel: PHASE1_T1_1_MCP_USAGE_TRACKER_2026_04_24
*
* 职责:
* - 读取 debug/activity-YYYY-MM-DD.jsonl 中 event=='mcp' 事件
* - 按 (server, tool) 聚合调用次数 + 首末次使用时间
* - 交叉 mcp-critical-allowlist.json 标记 critical 项
* - 输出 mcp-usage-week.json + 可读报告
*
* 用法:
* node scripts/mcp-usage-tracker.js # 7 天报告到 stdout
* node scripts/mcp-usage-tracker.js --days 30 # 30 天窗口
* node scripts/mcp-usage-tracker.js --json # 机器可读输出
* node scripts/mcp-usage-tracker.js --write # 持久化到 mcp-usage-week.json
*
* 非目的:
* - 不自动禁用 MCP (由 /mcp-prune 命令处理)
* - 不修改 .claude.json
* - 不记录工具参数 (仅计数)
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const HOME = process.env.USERPROFILE || process.env.HOME || os.homedir();
const CLAUDE_ROOT = process.env.CLAUDE_HOME ||
(fs.existsSync(path.join(HOME, '.claude')) ? path.join(HOME, '.claude') : HOME);
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
const ALLOWLIST_FILE = path.join(CLAUDE_ROOT, 'mcp-critical-allowlist.json');
const OUTPUT_FILE = path.join(CLAUDE_ROOT, 'mcp-usage-week.json');
const GLOBAL_CONFIG_FILE = path.join(HOME, '.claude.json');
function parseArgs(argv) {
const args = { days: 7, json: false, write: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--days' && argv[i + 1]) { args.days = parseInt(argv[++i], 10) || 7; }
else if (a === '--json') { args.json = true; }
else if (a === '--write') { args.write = true; }
else if (a === '-h' || a === '--help') { args.help = true; }
}
return args;
}
function safeReadJson(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch { return fallback; }
}
function daysAgo(n) {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
return d;
}
function parseDetail(detail) {
if (!detail || typeof detail !== 'string') return { server: null, tool: null };
const idx = detail.indexOf('/');
if (idx < 0) return { server: detail, tool: null };
return { server: detail.slice(0, idx), tool: detail.slice(idx + 1) };
}
function collectMcpEvents(windowDays) {
const events = [];
const cutoff = daysAgo(windowDays);
if (!fs.existsSync(DEBUG_DIR)) return events;
const files = fs.readdirSync(DEBUG_DIR)
.filter(n => /^activity-\d{4}-\d{2}-\d{2}\.jsonl$/.test(n))
.sort();
for (const fname of files) {
const m = fname.match(/^activity-(\d{4}-\d{2}-\d{2})\.jsonl$/);
if (!m) continue;
const fileDate = new Date(m[1] + 'T00:00:00Z');
if (fileDate < new Date(cutoff.toISOString().slice(0, 10) + 'T00:00:00Z')) continue;
const fullPath = path.join(DEBUG_DIR, fname);
let content;
try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
const lines = content.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (entry.event !== 'mcp') continue;
if (entry.ts && new Date(entry.ts) < cutoff) continue;
events.push(entry);
}
}
return events;
}
function aggregate(events, allServers) {
const stats = {};
for (const name of allServers) {
stats[name] = {
server: name, totalCalls: 0, successCount: 0, errorCount: 0,
firstUsed: null, lastUsed: null, tools: {}
};
}
for (const ev of events) {
const { server, tool } = parseDetail(ev.detail);
if (!server) continue;
if (!stats[server]) {
stats[server] = {
server, totalCalls: 0, successCount: 0, errorCount: 0,
firstUsed: null, lastUsed: null, tools: {}
};
}
const s = stats[server];
s.totalCalls++;
if (ev.success) s.successCount++;
else s.errorCount++;
if (!s.firstUsed || ev.ts < s.firstUsed) s.firstUsed = ev.ts;
if (!s.lastUsed || ev.ts > s.lastUsed) s.lastUsed = ev.ts;
if (tool) {
s.tools[tool] = s.tools[tool] || { count: 0, errorCount: 0 };
s.tools[tool].count++;
if (!ev.success) s.tools[tool].errorCount++;
}
}
return stats;
}
function identifyPruneCandidates(stats, criticalSet) {
const candidates = [];
for (const name of Object.keys(stats)) {
const s = stats[name];
if (criticalSet.has(name)) continue;
if (s.totalCalls > 0) continue;
candidates.push({ server: name, reason: 'zero-calls-in-window', totalCalls: 0 });
}
return candidates;
}
function formatReport(result) {
const lines = [];
lines.push('═══════════════════════════════════════════════════════════');
lines.push(' MCP Usage Report · ' + result.windowDays + 'd · ' + result.generated);
lines.push('═══════════════════════════════════════════════════════════');
lines.push('');
const sorted = Object.values(result.mcpStats).sort((a, b) => b.totalCalls - a.totalCalls);
const maxLen = Math.max.apply(null, sorted.map(s => s.server.length).concat(10));
lines.push('MCP 服务器'.padEnd(maxLen) + ' 调用 成功 错误 最后使用 标签');
lines.push('-'.repeat(maxLen + 58));
for (const s of sorted) {
const flag = result.criticalSet.includes(s.server) ? '★critical' : '';
const isPrune = result.pruneCandidates.some(p => p.server === s.server);
const tag = isPrune ? ' ⚠ prune-candidate' : flag;
const last = s.lastUsed ? s.lastUsed.slice(0, 16).replace('T', ' ') : '—'.padEnd(16);
lines.push(
s.server.padEnd(maxLen) +
' ' + String(s.totalCalls).padStart(4) +
' ' + String(s.successCount).padStart(5) +
' ' + String(s.errorCount).padStart(5) +
' ' + last.padEnd(16) +
' ' + tag
);
}
lines.push('');
lines.push('总 MCP 数: ' + Object.keys(result.mcpStats).length);
lines.push('活跃 (>0 调用): ' + sorted.filter(s => s.totalCalls > 0).length);
lines.push('剪枝候选: ' + result.pruneCandidates.length);
lines.push('★critical (永不剪枝): ' + result.criticalSet.length);
if (result.pruneCandidates.length > 0) {
lines.push('');
lines.push('剪枝候选清单 (运行 /mcp-prune --confirm 才会实际禁用):');
for (const c of result.pruneCandidates) {
lines.push(' - ' + c.server + ' (' + c.reason + ')');
}
}
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log('用法: node scripts/mcp-usage-tracker.js [--days N] [--json] [--write]');
process.exit(0);
}
const allowlist = safeReadJson(ALLOWLIST_FILE, { critical: [] });
const criticalSet = new Set((allowlist.critical || []).map(c => c.name));
const globalConfig = safeReadJson(GLOBAL_CONFIG_FILE, { mcpServers: {} });
const allServers = Object.keys(globalConfig.mcpServers || {});
const events = collectMcpEvents(args.days);
const mcpStats = aggregate(events, allServers);
const pruneCandidates = identifyPruneCandidates(mcpStats, criticalSet);
const result = {
schema_version: 1,
generated: new Date().toISOString(),
windowDays: args.days,
totalEvents: events.length,
mcpStats,
pruneCandidates,
criticalSet: Array.from(criticalSet)
};
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatReport(result));
}
if (args.write) {
const tmp = OUTPUT_FILE + '.tmp';
try {
fs.writeFileSync(tmp, JSON.stringify(result, null, 2), 'utf8');
fs.renameSync(tmp, OUTPUT_FILE);
if (!args.json) console.log('\n已写入: ' + OUTPUT_FILE);
} catch (e) {
console.error('写入失败: ' + e.message);
process.exit(2);
}
}
process.exit(0);
}
if (require.main === module) main();
module.exports = { collectMcpEvents, aggregate, identifyPruneCandidates, parseDetail };