bookworm-smart-assistant/hooks/session-start-mcp-probe.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

150 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'use strict';
/**
* SessionStart MCP Probe (Phase 1 · T1.2)
* sentinel: PHASE1_T1_2_MCP_PROBE_HOOK_2026_04_24
*
* 事件: UserPromptSubmit (Bookworm 无 SessionStart 键,用 UserPromptSubmit + 日期守卫替代)
* 目的: 每日首次会话轻量探测 MCP 配置健康度,结果写 logs/mcp-health-<date>.json
*
* 预算:
* - 今日 snapshot 已存在: <5ms (fs.existsSync 快速返回)
* - 首次运行: <500ms (22 个 MCP 的命令存在性检查)
*
* 容错:
* - Feature flag 关闭: 立即 exit 0
* - 任何异常: exit 0 (fail-open永不阻断用户输入)
*
* 不做的事:
* - 不 spawn MCP 子进程 (太慢,交给 /mcp-probe 技能)
* - 不做实际 HTTP 请求
* - 不修改 .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 LOGS_DIR = path.join(CLAUDE_ROOT, 'logs');
const FEATURE_FLAGS_FILE = path.join(CLAUDE_ROOT, '.bookworm-features.json');
const TODAY = new Date().toISOString().slice(0, 10);
const HEALTH_FILE = path.join(LOGS_DIR, 'mcp-health-' + TODAY + '.json');
const CONFIG_FILE = path.join(HOME, '.claude.json');
function safeExit() { process.exit(0); }
// 已知在 PATH 上的命令(避免 which 调用开销)
const WELL_KNOWN_CMDS = /^(npx|node|python|python3|bash|sh|pnpm|yarn|uvx|go|deno|bun)$/;
function commandPlausible(cmd) {
if (!cmd || typeof cmd !== 'string') return false;
if (path.isAbsolute(cmd)) return fs.existsSync(cmd);
// Windows 可能配置带 .cmd/.exe/.bat 后缀 (e.g. npx.cmd),剥离后比对已知命令
const stripped = cmd.replace(/\.(exe|cmd|bat)$/i, '');
if (WELL_KNOWN_CMDS.test(stripped)) return true;
// 回退: 按 PATH 扫描 (受限于 PATH 数量10-30 次 existsSync 可接受)
const PATH_DIRS = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
const candidates = [cmd, cmd + '.exe', cmd + '.cmd', cmd + '.bat'];
for (const dir of PATH_DIRS) {
for (const c of candidates) {
try { if (fs.existsSync(path.join(dir, c))) return true; } catch {}
}
}
return false;
}
function probeMcp(name, cfg) {
try {
if (cfg && (cfg.type === 'http' || cfg.type === 'sse')) {
const hasUrl = !!cfg.url && /^https?:\/\//.test(cfg.url);
return {
kind: cfg.type,
url: cfg.url || null,
urlValid: hasUrl,
reachable: hasUrl
};
}
// 默认 stdio
const command = (cfg && cfg.command) || '';
const exists = commandPlausible(command);
return {
kind: 'stdio',
command,
commandExists: exists,
reachable: exists
};
} catch (e) {
return { error: String(e.message || e), reachable: false };
}
}
function main() {
// [P0-2] SESSION_ONCE_v1 — 会话级去重 (<1ms)
try { if (require('./lib/session-once.js').hasRun('mcp-probe')) return safeExit(); } catch {}
// Feature flag 检查
try {
if (fs.existsSync(FEATURE_FLAGS_FILE)) {
const flags = JSON.parse(fs.readFileSync(FEATURE_FLAGS_FILE, 'utf8'));
if (flags && flags.mcp_probe === false) return safeExit();
}
} catch {}
// 今日 snapshot 已存在 → 跳过 (日级守卫)
if (fs.existsSync(HEALTH_FILE)) return safeExit();
// 读取 MCP 配置
let mcpServers = {};
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
mcpServers = cfg.mcpServers || {};
} catch {
return safeExit();
}
const results = {};
let reachable = 0;
let unreachable = 0;
const unreachableList = [];
for (const name of Object.keys(mcpServers)) {
const r = probeMcp(name, mcpServers[name]);
results[name] = r;
if (r.reachable) reachable++;
else { unreachable++; unreachableList.push(name); }
}
const snapshot = {
schema_version: 1,
date: TODAY,
probedAt: new Date().toISOString(),
probeKind: 'lightweight-static',
totalMcps: Object.keys(mcpServers).length,
reachable,
unreachable,
unreachableList,
results
};
// 确保 logs 目录
try { fs.mkdirSync(LOGS_DIR, { recursive: true }); } catch {}
// 原子写
try {
const tmp = HEALTH_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(snapshot, null, 2), 'utf8');
fs.renameSync(tmp, HEALTH_FILE);
// [P0-2] SESSION_ONCE_v1
try { require('./lib/session-once.js').markRun('mcp-probe'); } catch {}
} catch {}
safeExit();
}
if (require.main === module) main();
module.exports = { probeMcp, commandPlausible };