- 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)
236 lines
8.0 KiB
JavaScript
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 };
|