bookworm-smart-assistant/skills/mcp-probe/SKILL.md

275 lines
9.7 KiB
Markdown
Raw Permalink Normal View History

---
name: mcp-probe
version: 1.0.0
description: |
MCP 服务器连通性体检。对 .claude.json 中注册的全部 MCP (stdio + http) 发
initialize 握手包或 HEAD 请求,输出每个服务器的启动耗时、健康状态和根因诊断。
触发词: "体检MCP", "测MCP", "MCP健康检查", "MCP连通性", "mcp-probe",
"probe mcp", "MCP全量测试", "MCP诊断", "所有MCP是否正常"。
maturity: stable
allowed-tools:
- Bash
- Read
- Edit
- Grep
- Glob
---
# /mcp-probe — MCP 连通性体检
对用户 `~/.claude.json` 中注册的全部 MCP 服务器做一次并/串行健康检查,输出结构化报告。
## 数据源
```
配置: ~/.claude.json (mcpServers 字段)
脚本: ~/mcp-probe.js (探测实现)
结果: ~/mcp-probe-result.json (每次运行覆写)
```
## 探测策略
| 类型 | 探测方法 | 健康判据 |
|------|---------|---------|
| **stdio** (command+args) | spawn 子进程 → stdin 发 `initialize` JSON-RPC 包 → 监听 stdout 回包 | 收到 `{"id":1,"result":{...}}` 即 OK |
| **http** (type:http, url) | HTTPS HEAD 请求 | HTTP < 500 OK (401/405 表示 auth-required 也算通) |
| **超时** | 默认 8 秒 | 超过判为 TIMEOUT |
| **.cmd/.bat** | Windows 需 `shell: true` (Node 18.20+ CVE-2024-27980) | 已内置 |
## 执行流程
### Step 1 — 运行探测脚本
```bash
cd ~
node mcp-probe.js
```
**预期耗时**: 3-5 分钟(串行 28 个服务器,每个最多 8s + stdio 启动时间)。
如果脚本不存在,先提示用户: "检测到 `mcp-probe.js` 不存在,需要我先创建吗?" 然后基于本 Skill 末尾附录的模板生成。
### Step 2 — 读取结果
```bash
cat ~/mcp-probe-result.json
```
JSON 结构:
```json
[
{
"name": "github",
"kind": "stdio",
"status": "OK",
"detail": "proto=2024-11-05 server=github-mcp-server",
"ms": 1735,
"stderrTail": ""
},
...
]
```
**状态枚举**:
- `OK` — 握手成功或 HTTP 可达
- `TIMEOUT` — 超时未响应(可能首次安装 npm 包慢,建议单独重测 30s 超时)
- `CRASHED` — 启动后立即 exit看 stderrTail 定位
- `SPAWN_ERROR` — 进程都没起来PATH 问题 / 命令缺失)
- `NET_ERROR` — HTTP 网络失败
- `HTTP_ERROR` — HTTP 返回 5xx
### Step 3 — 输出报告
固定格式 markdown 表格,按状态分组:
```markdown
## MCP 连通性体检报告
**总计**: 28 个 | **OK**: N | **FAIL**: M | **TIMEOUT**: K
### ✅ 健康 (N)
| # | MCP | 类型 | 启动耗时 | serverInfo |
|---|-----|------|---------|-----------|
| 1 | github | stdio | 1.7s | github-mcp-server |
| ... | ... | ... | ... | ... |
### ❌ 异常 (M+K)
| # | MCP | 状态 | 可能原因 | 建议修复 |
|---|-----|------|---------|---------|
| 1 | slack | CRASHED | 缺环境变量 | 检查 .claude.json env 段 |
```
### Step 4 — 根因诊断(对每个 FAIL
按 stderrTail 关键字匹配常见模式:
| stderrTail 关键字 | 根因 | 修复建议 |
|------|------|---------|
| `Please set XXX env` | 缺环境变量 | 在 `.claude.json.mcpServers.<name>.env` 补上 |
| `ERR_MODULE_NOT_FOUND` | npx 缓存 ESM/CJS 错配 | 清 `_npx/<hash>/` 目录后重试 |
| `Lock compromised` / `ECOMPROMISED` | npm 子依赖 postinstall 失败 | 看 npm log 找失败的子包,常见 puppeteer/sharp/canvas |
| `spawn EINVAL` | Node 18.20+ 拒绝直接 spawn .cmd | 脚本应已修; 若仍出现则升级脚本 |
| `'xxx.cmd' 不是内部或外部命令` | PATH 问题 | `where xxx.cmd` 确认安装 |
| `Unauthorized` / 401 | HTTP MCP 需要 OAuth | 属正常(需用户在 Claude Code 内授权)|
| `ECONNREFUSED` / `ETIMEDOUT` | 代理或网络问题 | 检查 clash/verge 代理状态 |
### Step 5 — 主动给修复方案
对每个 FAIL不止列问题要主动问用户"需要我直接修复吗?" — 如果用户同意:
- **缺环境变量**: 引导用户获取 token → Edit `.claude.json` 写入
- **npm 缓存损坏**: 直接执行清理命令(先确认用户授权)
- **puppeteer 下载失败**: 加 `PUPPETEER_SKIP_DOWNLOAD=true` + `PUPPETEER_EXECUTABLE_PATH`
- **HTTP 401**: 提示用户在 Claude Code 运行 `/mcp` → Authenticate
## 可选子命令
用户可在触发时附加参数:
### `/mcp-probe quick`
只测 HTTP MCP秒级跳过 stdio 启动慢的。
### `/mcp-probe <name>`
只测指定的一个 MCP用 30s 超时(首次安装可能慢)。
### `/mcp-probe diff`
对比上次 `mcp-probe-result.json` 和这次结果,找出状态变化的 MCP从 OK 变 FAIL 或反之)。
### `/mcp-probe fix`
全量探测 + 对 FAIL 项自动应用已知修复(需用户最终确认)。
## 输出约定
- 使用 markdown 表格,不用 emoji 滥用
- 启动耗时保留 1 位小数(秒)
- stderrTail 超 120 字符截断加 `...`
- 如果全部 OK用一句话总结: "**28/28 MCP 全绿,系统健康**"
- 如果有 FAIL末尾附修复优先级 (P0/P1/P2)
## 注意事项
- **不修改业务配置**: 修复仅限 `env` 段补全、npx 缓存清理、PUPPETEER 相关 env
- **不回显 token**: 若探测中读到 stderr 含 `xoxb-` / `sk-` 等格式的凭证,要脱敏后再显示
- **并发安全**: 脚本采用串行探测避免 CPU/IO 风暴,不建议改成 Promise.all
- **幂等性**: 重复执行不产生副作用result.json 每次覆写
---
## 附录: mcp-probe.js 模板
若脚本丢失,用以下模板重建到 `~/mcp-probe.js`:
```javascript
// MCP connectivity probe
const { spawn } = require('child_process');
const https = require('https');
const fs = require('fs');
const CONFIG = JSON.parse(fs.readFileSync(require('os').homedir() + '/.claude.json', 'utf8'));
const servers = CONFIG.mcpServers || {};
const TIMEOUT_MS = 8000;
function probeStdio(name, cfg) {
return new Promise((resolve) => {
const start = Date.now();
let stdout = '', stderr = '', settled = false, child;
const done = (status, detail) => {
if (settled) return;
settled = true;
try { child && child.kill('SIGKILL'); } catch (_) {}
resolve({ name, kind: 'stdio', status, detail, ms: Date.now() - start,
stderrTail: stderr.split('\n').filter(Boolean).slice(-3).join(' | ').substring(0, 200) });
};
try {
const env = Object.assign({}, process.env, cfg.env || {});
const needsShell = /\.(cmd|bat)$/i.test(cfg.command);
child = spawn(cfg.command, cfg.args || [], {
env, stdio: ['pipe', 'pipe', 'pipe'],
shell: needsShell, windowsHide: true,
});
} catch (e) { return done('SPAWN_ERROR', e.message); }
child.on('error', (e) => done('SPAWN_ERROR', e.message));
child.on('exit', (code, sig) => {
if (settled) return;
done(code === 0 ? 'EXITED_OK' : 'CRASHED',
`exit=${code} sig=${sig} stderr="${stderr.slice(-150).replace(/\n/g, ' ')}"`);
});
child.stdout.on('data', (d) => {
stdout += d.toString();
for (const line of stdout.split('\n')) {
if (line.includes('"jsonrpc"') && line.includes('"result"')) {
try {
const msg = JSON.parse(line);
if (msg.id === 1 && msg.result) {
const proto = msg.result.protocolVersion || '?';
const srv = (msg.result.serverInfo && msg.result.serverInfo.name) || '?';
return done('OK', `proto=${proto} server=${srv}`);
}
} catch (_) {}
}
}
});
child.stderr.on('data', (d) => { stderr += d.toString(); });
const req = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {},
clientInfo: { name: 'mcp-probe', version: '0.0.1' } },
}) + '\n';
try { child.stdin.write(req); } catch (_) {}
setTimeout(() => done('TIMEOUT', `no response in ${TIMEOUT_MS}ms`), TIMEOUT_MS);
});
}
function probeHttp(name, url) {
return new Promise((resolve) => {
const start = Date.now();
const u = new URL(url);
const req = https.request({
host: u.host, path: u.pathname + u.search, method: 'HEAD',
timeout: TIMEOUT_MS, headers: { 'User-Agent': 'mcp-probe/0.1' },
}, (res) => {
resolve({ name, kind: 'http', status: res.statusCode < 500 ? 'OK' : 'HTTP_ERROR',
detail: `HTTP ${res.statusCode}`, ms: Date.now() - start, stderrTail: '' });
res.resume();
});
req.on('error', (e) => resolve({ name, kind: 'http', status: 'NET_ERROR',
detail: e.code + ':' + e.message, ms: Date.now() - start, stderrTail: '' }));
req.on('timeout', () => { req.destroy(); });
req.end();
});
}
(async () => {
const names = Object.keys(servers);
console.log(`probing ${names.length} MCP servers (TIMEOUT=${TIMEOUT_MS}ms)...`);
const results = [];
for (const name of names) {
const cfg = servers[name];
let r;
if (cfg.type === 'http' || (cfg.url && /^https?:/.test(cfg.url))) {
r = await probeHttp(name, cfg.url);
} else if (cfg.command) {
r = await probeStdio(name, cfg);
} else {
r = { name, kind: 'unknown', status: 'UNKNOWN_CONFIG',
detail: JSON.stringify(cfg).slice(0, 100), ms: 0, stderrTail: '' };
}
const tag = r.status === 'OK' ? 'OK ' : (r.status === 'TIMEOUT' ? '???' : 'FAIL');
console.log(`[${tag}] ${r.name.padEnd(22)} ${String(r.ms).padStart(5)}ms ${r.status.padEnd(14)} ${r.detail}`);
if (r.stderrTail) console.log(` stderr: ${r.stderrTail}`);
results.push(r);
}
console.log('\n=== SUMMARY ===');
const byStatus = {};
for (const r of results) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
for (const [k, v] of Object.entries(byStatus).sort((a, b) => b[1] - a[1])) {
console.log(` ${k.padEnd(16)} ${v}`);
}
fs.writeFileSync(require('os').homedir() + '/mcp-probe-result.json', JSON.stringify(results, null, 2));
console.log('\nwrote mcp-probe-result.json');
})();
```