bookworm-smart-assistant/scripts/patches/patch-phase1-mcp-observability.js

309 lines
10 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
'use strict';
/**
* Phase 1 · T1.1 补丁 MCP 可观测底座落地
*
* 目的: 创建 scripts/mcp-usage-tracker.js (纯新增不修改既有文件)
*
* 产出:
* - scripts/mcp-usage-tracker.js (MCP 使用率聚合脚本)
*
* 幂等:
* - 已存在且内容含 sentinel 则跳过
* - 已存在但内容不同则 .bak 备份后覆盖
*
* sentinel: PHASE1_T1_1_MCP_USAGE_TRACKER_2026_04_24
*
* 安全:
* - 不修改既有 hooks / settings.json / .claude.json
* - 不引入 eval / exec
* - 原子写: tmp + rename
*/
const fs = require('fs');
const path = require('path');
const CLAUDE_ROOT = path.join(__dirname, '..', '..');
const SCRIPTS_DIR = path.join(CLAUDE_ROOT, 'scripts');
const TARGET = path.join(SCRIPTS_DIR, 'mcp-usage-tracker.js');
const SENTINEL = 'PHASE1_T1_1_MCP_USAGE_TRACKER_2026_04_24';
const CONTENT = `#!/usr/bin/env node
'use strict';
/**
* MCP 使用率追踪器 (Phase 1 · T1.1)
* sentinel: ${SENTINEL}
*
* 职责:
* - 读取 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 };
`;
function main() {
if (!fs.existsSync(SCRIPTS_DIR)) {
console.error('[patch-phase1-T1.1] scripts 目录不存在:', SCRIPTS_DIR);
process.exit(1);
}
// 幂等检查
if (fs.existsSync(TARGET)) {
const current = fs.readFileSync(TARGET, 'utf8');
if (current.includes(SENTINEL) && current === CONTENT) {
console.log('[patch-phase1-T1.1] 已落地且内容一致,跳过');
process.exit(0);
}
// 已存在但内容不同 → 备份
const bak = TARGET + '.bak.phase1-t1.1';
fs.copyFileSync(TARGET, bak);
console.log('[patch-phase1-T1.1] 已备份旧版本:', bak);
}
// 原子写: tmp + rename
const tmp = TARGET + '.tmp';
fs.writeFileSync(tmp, CONTENT, 'utf8');
fs.renameSync(tmp, TARGET);
console.log('[patch-phase1-T1.1] 已写入:', TARGET);
// 语法自检 (require 成功即通过)
try {
delete require.cache[require.resolve(TARGET)];
require(TARGET);
console.log('[patch-phase1-T1.1] 语法自检 PASS');
} catch (e) {
console.error('[patch-phase1-T1.1] 语法自检失败:', e.message);
process.exit(3);
}
console.log('[patch-phase1-T1.1] sentinel:', SENTINEL);
console.log('[patch-phase1-T1.1] 完成。验证: node scripts/mcp-usage-tracker.js --days 7');
process.exit(0);
}
main();