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

275 lines
9.7 KiB
Markdown
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.

---
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');
})();
```