#!/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();