mcp-server-inspector
v0.1.1
Published
Health-check any MCP server before you trust it: does it actually run, what does it really expose, how alive is it, and what did we NOT test.
Maintainers
Readme
mcp-server-inspector
English | 中文
在把一个 MCP 服务器接进你的 agent 之前,先搞清楚你到底知道它什么。
MCP 生态现在有十几万个服务器(见下方「生态数据与出处」),而决定装哪一个的依据,通常是一段 README。README 描述的是意图。当你把一个服务器接进持有你凭证的 agent 时,真正要紧的是它跑起来会做什么。这两件事不一致的频率足够高,所以唯一靠谱的办法是实测。
mcp-server-inspector 做一件事:真连上去,然后如实报告。
npx mcp-server-inspector @modelcontextprotocol/server-filesystem它是体检报告,不是目录
这一条要说在最前面,因为很容易被混为一谈。
| | 做什么 | 不做什么 | |---|---|---| | Registry(注册表) | 收录、检索、分发服务器包 | 不告诉你这个包跑起来会怎样 | | mcp-server-inspector | 启动它、跟它握手、看它真实暴露什么、与它自己宣称的对照 | 不收录、不检索、不替你决定装哪个 |
Registry 回答「有哪些」,mcp-server-inspector 回答「这一个到底是什么情况」。两者是互补的,不是竞争关系。
它也不是安全审计。理由在下面「我们没测什么」里说清楚了——那一节是这份文档里最重要的一节。
它做三件事
1. 真连上去(不是读文档猜)
按 stdio 传输规范走完整握手:
spawn 子进程
→ initialize (协议版本 2025-11-25)
→ notifications/initialized
→ tools/list (跟着 nextCursor 翻页翻到底)
→ 终止子进程翻页这件事本身就是必须做对的:只读第一页会少报工具数,而「少报」恰恰是本工具存在的理由,所以它自己绝不能犯这个错。
2. 读它的静态声明
读 package.json 和 README:依赖数量、安装期脚本、许可证、仓库地址、发版时间、维护者数量。
安装期脚本只报告,不执行。这一点没有例外。
而且这里必须区分两类脚本,因为它们的威胁模型完全不同:
| 类别 | 脚本 | 在谁的机器上跑 | 权重 |
|---|---|---|---|
| 消费端 | preinstall / install / postinstall | 使用者 npm install 时自动执行 | −20(risk) |
| 维护端 | prepare / prepublishOnly | 维护者自己的构建/发布流程 | −1(info,仅披露) |
一开始把两者混为一谈,结果是几乎每个 TypeScript 包都被判高危。实测在官方的 @modelcontextprotocol/server-everything 上,光这一个混淆就吃掉 20 分,把 MCP 项目自己的参考实现从 usable 压到 caution(50/100,exit 1)。prepare 是 TypeScript 项目的标准构建步骤,它不在使用者安装时执行,所以不是消费端执行面。拆开之后同一服务器回到 74/100、exit 0。
3. 把差距写成报告
报告固定分三段:
一、事实 可复现的观测,谁跑都一样
二、判断 基于上面事实的结论,可以不同意
三、没测什么 无条件打印第三段永远打印,哪怕分数满分。
为什么失败模式必须可区分
这是本工具最重要的工程决定。
探测一个服务器会怎么失败?实践中大多数失败不是协议错误,而是挂起、崩溃、输出根本不是 JSON。一个统一的「failed」标签会把最有用的信息抹掉——因为「它挂住了」和「它立刻就退了」指向完全不同的修法。
所以探测结果有 7 种状态,每种都是独立标签:
| 状态 | 含义 | 通常意味着 |
|---|---|---|
| ok | 握手完成 | 可用 |
| no-handshake | 启动了,但 initialize 没有合法响应 | 协议实现不对;客户端接不上 |
| no-tools | 握手正常,但 0 个工具 | 宣称的能力不存在 |
| crash | 响应完成前自行退出 | 缺环境变量、缺依赖、启动即崩 |
| timeout | 没退出,也没回答 | 死锁、等输入、阻塞 |
| not-json | stdout 不是合法协议帧 | 往协议通道里打了日志/banner |
| spawn-error | 进程根本没能拉起来 | 二进制不存在、路径错、权限不对 |
crash 和 spawn-error 必须分开,而且这里踩过一个真实的坑:在 Windows 上,当二进制不存在时,Node 仍然会往 child.exitCode 里塞一个错误码(实测 -4058,即 ENOENT)。如果照直读那个值,一个「命令写错了」会被报成「服务器崩溃了」——修法完全不同。所以本工具把启动失败锁存下来,任何下游观测都不能覆盖它,并且把 exitCode 归零为 null(一个从未存在过的进程没有退出码)。
计时要量的是服务器,不是 npm
这一节记录一次被实测推翻的假设,因为推翻的过程本身说明了「先测再改」为什么重要。
问题是:谁在为启动买单
本工具主要通过 npx 启动目标。曾观察到同一个服务器经 npm 启动要 3.3 秒,直接 node 启动只要 0.24 秒。如果拿墙钟(elapsedMs)去判断服务器快慢,那 3 秒其实是 npm 的启动开销,却被记在了服务器账上——而评分里有一条「握手很快 +4」,npm 启动的服务器永远拿不到,healthy 档事实上对最常被安装的那批服务器不可达。
第一个假设,以及它为什么错了
假设:npm 的开销发生在我们写入 initialize 之前,所以只要在写入的那一刻开始计时,就能把 npm 排除掉。
于是给探针加了 requestWrittenAt,从写入时刻算到响应。实测(npx -y @modelcontextprotocol/server-sequential-thinking):
21ms spawn 返回
139ms initialize 已写入
5963ms 响应到达写入确实发生在 139ms,响应在 5963ms。filesystem 服务器更清楚——它自己的启动横幅出现在 4864ms,响应在 4881ms。
结论:假设错了。 npm 的引导不是「写入之前的前奏」,我们的写入被缓冲进了它的启动过程,整段开销都落在这一轮往返里面。所以「从写入开始计时」和「量墙钟」得到的是同一个数字(实测 12098ms vs 12144ms),什么都没修好。
正确的做法:按启动路径做算术
唯一可靠的分界线是启动方式。同一个构建产物,两种启动方式各跑三次:
经 npx -y <pkg> 3445, 3399, 3502ms 平均 3449ms
经 node <同一个文件> 777, 717, 741ms 平均 745ms
差 2704ms这 2704ms 是 npm,不是服务器,而且每次启动都要付一遍(冷启动 3544ms vs 热启动 3552ms,说明是引导开销而非下载)。
所以探针现在报两个数:
| 字段 | 含义 | 是否参与评分 |
|---|---|---|
| elapsedMs | 墙钟,含我们这边的启动开销 | 否 |
| launcherMs | 归给 npm/npx 的部分(常量 2500ms,向下取整以免过度扣减) | — |
| serverMs | elapsedMs − launcherMs(仅在确实用了启动器时扣) | 是 |
常量 2500 无法在单次运行中测出来(两段开销交叠在一起,没有带内信号能分开),所以它来自上面那组对照实验。校验方法:同一个服务器分别经 npm 和经 node 启动,serverMs 相差仅 122ms(659 vs 781)——说明这个常量是公允的。
单次采样不够,所以有了 --repeat
三次采样的离散度很大:同一个服务器从 585ms 到 1630ms 都有。拿单次结果下判断,下一次运行就可能翻盘。
所以加了 --repeat <次数>(默认 1,最多 10)。多次运行时取最小值而非平均——噪声是单边的:机器繁忙只会增加延迟,不会减少,所以最快的那次最接近服务器的真实成本,取平均反而会把我们自己的调度抖动算进服务器头上。
阈值也随之分档:只有 1 次采样时,必须明显低于界线才给「握手很快」(否则下次运行就翻);有 3 次以上采样时,最小值就是可信估计,按实给分。报告的措辞也跟着改——单次采样就写「单次采样」,多次就写「3 次取最快」,不假装单次采样能撑起精确结论。
结果
修好之后,四个官方服务器(--repeat 3):
| 服务器 | 3 次采样(serverMs) | 取最快 | 结论 |
|---|---|---|---|
| server-memory | 644 / 617 / 602 | 602ms | 健康 81 |
| server-sequential-thinking | 692 / 642 / 609 | 609ms | 健康 81 |
| server-filesystem | 1254 / 988 / 687 | 687ms | 可用 78 |
| server-everything | 1539 / 966 / 808 | 808ms | 可用 78 |
修之前四个全部卡在 69–77 的「可用」档。这是第一次有真实服务器拿到「健康」——而能拿到,只是因为采样揭示了它们真实成本约 600ms,之前那些 1500ms+ 的单次数字多半是我们自己的噪声。
信任评分怎么算
分数是一个可手算核对的量,不是黑箱:
总分 = 60(中性起点:"两个方向都没观察到")
+ 所有命中的信号权重
然后夹到 [0, 100]权重表集中在 lib/trust.js 的 WEIGHTS 里,一共 33 项,放在一处就是为了让人能自己加一遍。几项主要的:
| 信号 | 权重 |
|---|---|
| probe.spawn-error(起不来) | −50 |
| probe.no-handshake(接不上) | −45 |
| probe.crash(崩溃) | −40 |
| instructions.injection-shaped(全局指令里有注入形状) | −35 |
| probe.timeout(卡死) | −30 |
| tool-count.claimed-mismatch-large(宣称与实测工具数大幅不符) | −28 |
| claims.install-hooks(有消费端安装期脚本) | −20 |
| tool.dangerous-description(工具描述含可疑措辞) | −18 |
| claims.maintainer-hooks(有维护端脚本,仅披露) | −1 |
| good.recent-release(近期有发版) | +8 |
| good.tools-match(宣称与实测一致) | +6 |
对应五个档位:
| 档位 | 条件 | |---|---| | 健康 | 实测通过,分数 ≥ 80 | | 可用(有需留意项) | 实测通过,分数 ≥ 60 | | 需谨慎 | 分数 ≥ 35,或握手正常但 0 工具 | | 不建议使用 | 分数 < 35 | | 无法使用 | 探测未能成立 |
几条刻意的设计取舍:
- 没跑过探测就不给自信的分。
--offline模式固定落在「可用」档,因为纯靠元数据得不出运行时结论。报告会明说「只做了静态检查(未连接服务器)」。 - 元数据没读到就不扣分。 早期版本对「没有仓库地址」「没有 MCP 标记」扣分,但当目标是一个裸脚本路径时,它本来就没有
package.json——那是我们输入的问题,不是服务器的问题。为自己的检查没跑成而惩罚对方,正是本工具要防的那种无根据判断,所以它自己不能犯。这个 bug 被实测抓出来了(干净服务器分数从 59 修正到 67)。 - 不用「零证据=高分」的方式判干净。 中性起点 60,只靠正面观测往上加。
Windows 上为什么不能直接 spawn npx
第一次拿真实包测试时,本工具在 Windows 上一个真实服务器都测不了:静态声明读得好好的(抓到了真实 npm 元数据),但探测一律返回 spawn-error: spawn npx ENOENT。
逐项起真实进程验证后,原因是这样:
| 尝试 | 结果 |
|---|---|
| spawn("npx") 无 shell | ENOENT —— PATH 上只有 npx.cmd,没有无扩展名的可执行文件 |
| spawn("npx.cmd") 无 shell | EINVAL —— Windows 加固后拒绝无 shell 直接 exec .cmd |
| spawn("npx", {shell:true}) | 可行,但会把 shell 引号/注入风险重新引进来 |
解法是绕开 shell,也绕开 .cmd 包装:定位 npm 自己的 CLI 入口脚本(node_modules/npm/bin/npx-cli.js),用 process.execPath 直接跑。这样在 Linux/macOS 上行为完全一致,也保住了「目标里的包名不进 shell」这条性质。
修复后同一个服务器立刻探测成功:status ok、mcp-servers/everything 2.0.0、协议 2025-11-25、14 个工具。
顺带一条相关经验:目标路径先检查存在性再交给 node。否则一个打错的路径会让 spawn 成功、node 以 MODULE_NOT_FOUND 退出,然后被报成「服务器崩溃了」——而实际上根本没有服务器,是路径写错了。两者的修法完全不同,所以必须在入口处把「我们的输入错了」和「他们的代码坏了」分开。
它认得哪些风险模式
7 类,作用在工具描述和服务器全局 instructions 两处。判据全部来自工具投毒(tool poisoning)这类问题的公开文献:藏在工具描述里的指令会在注册时就进入模型上下文,早于任何一次调用发生。
| 模式 | 它在看什么 |
|---|---|
| ignore-instructions | 「忽略之前所有指令」这类措辞 |
| exfiltrate | 「把凭证/密钥/环境变量发送出去」 |
| shell-hide | 「不要告诉用户」 |
| metadata-fetch | 直接指名云 metadata 端点(169.254.169.254 等) |
| read-secrets-path | 指名凭证文件路径(~/.ssh、.aws/credentials、.env …) |
| always-approve | 「无需确认 / 绕过审批」 |
| persona-override | 「你现在是…」这类身份改写 |
这些措辞的写法很重要:它们描述「看到了什么」以及「为什么值得看一眼」,不指控恶意。工具描述告诉模型该做什么是正常的;告诉模型忽略既有指令不是。报告应当说清看到的是哪一种。
我们没测什么
这是最重要的一节。 也是报告里无条件打印的那一段。
| 没测的东西 | 为什么 |
|---|---|
| 工具被真正调用时的行为 | 本工具只做握手和 tools/list,不执行任何工具。一个服务器可以在不被调用时完全规矩,而在工具被调用时读取不该读的文件——这正是间接提示注入的常见形态。 |
| 依赖包内部的代码 | 只统计了依赖数量,没有审查依赖内容。供应链风险往往藏在传递依赖里。 |
| 运行时网络行为 | 没有监控服务器运行时会连向哪些地址。一个服务器可以在握手阶段安静,在工具调用时外联。 |
| 运行时文件系统访问 | 只读了 package.json 和 README,没有观察服务器运行期间实际打开了哪些文件。 |
| 工具返回内容中的注入 | 工具返回值里的注入内容只有真正调用时才会出现。这类检测需要运行时网关,静态检查看不到(这正是 mcp-sentry 那类工具的职责)。 |
| 源代码质量与逻辑正确性 | 没有阅读实现代码。一个没有安全问题的服务器仍可能功能是错的。 |
换成一句话:没发现问题,只代表它通过了上面有限几项检查,不代表它是安全的。
想在任何时候查看这份清单(不需要给目标):
npx mcp-server-inspector --coverage装上与用法
# 一次性运行,不安装
npx mcp-server-inspector <目标>
# 或装到全局
npm install -g mcp-server-inspector
mcpx <目标>零依赖。只需要 Node ≥ 20。没有配置文件、没有守护进程、没有状态——一个「装之前跑一次」的工具,不该要求你先配置点什么。
目标写法
mcpx @modelcontextprotocol/server-filesystem # npm 包名 → 自动走 npx
mcpx ./my-mcp-server # 本地目录
mcpx "npx -y some-mcp-server --flag" # 完整命令行
mcpx ./dist/server.js # 本地脚本 → 用 node 拉起两条刻意的行为:
- 显式写出的调用方式原样保留。 早期版本会把
npx -y pkg重新包一次,生成npx -y npx -y pkg——已经修掉了。 - 本地路径不存在会当场报错,而不是交给 node 去跑。否则你会看到一个「服务器崩溃」的诊断,附带
MODULE_NOT_FOUND——那个诊断是错的:根本没有服务器,是路径写错了。报告会明确写成「探测目标不存在(输入问题,不是服务器问题)」,不让自己的输入错误算到对方头上。
选项
| 选项 | 作用 |
|---|---|
| --offline | 不连接服务器,只做静态检查(快,但看不到运行时真相) |
| --timeout <毫秒> | 探测超时,默认 15000 |
| --repeat <次数> | 重复探测几次、取最快的一次做耗时判断(默认 1,最多 10)。单次计时有噪声,在意耗时结论时用 3 |
| --json | 输出 JSON(含 coverage 覆盖范围说明),适合接 CI |
| --quiet | 不打印进度提示 |
| --coverage | 单独打印「会检查 / 不会检查」两份清单 |
| -h, --help / -v, --version | 帮助 / 版本 |
退出码
| 码 | 含义 |
|---|---|
| 0 | 健康 / 可用 |
| 1 | 需谨慎 / 不建议 / 无法使用 |
| 2 | 用法错误 |
在代码里用
import { inspect } from "mcp-server-inspector";
const r = await inspect("@modelcontextprotocol/server-filesystem", {
timeoutMs: 10000,
});
console.log(r.trust.band, r.trust.score); // "healthy" 82
console.log(r.probe.status); // "ok"
console.log(r.probe.toolCount); // 14(翻页翻完的真实数量)
console.log(r.claims.installScripts); // [](只报告,不执行)
console.log(r.trust.untested.length); // 6(永远存在的诚实清单)也可以只取需要的部分:
import { probe } from "mcp-server-inspector/probe"; // 只做真连探测
import { claimsFor } from "mcp-server-inspector/claims"; // 只读静态元数据
import { assess } from "mcp-server-inspector/trust"; // 只做评分
import { render, toJson } from "mcp-server-inspector/report";JSON 输出结构
{
"tool": "mcp-server-inspector",
"schemaVersion": 1,
"target": "@scope/name",
"probed": true,
"package": { "source": "npm", "name": "…", "installScripts": [], "…": "…" },
"runtime": { "status": "ok", "toolCount": 14, "tools": [], "exitCode": null },
"verdict": { "band": "healthy", "score": 82, "summary": "…" },
"findings": [ { "id": "…", "severity": "…", "detail": "…", "evidence": "…", "weight": 0 } ],
"coverage": {
"tested": [ "…" ],
"notTested": [ { "id": "…", "title": "…", "why": "…" } ],
"disclaimer": "未发现问题 ≠ 安全。…"
}
}--offline 模式下 runtime 是 null,不是空对象——因为确实没有运行时观测可报。消费这份 JSON 时请显式处理这种情形:
{ "probed": false, "runtime": null, "verdict": { "band": "usable" } }coverage.notTested 和 coverage.disclaimer 始终存在,包括一切正常的时候、也包括 runtime 为 null 的时候。任何消费这份 JSON 的地方都应该把它传下去。
开发
npm test # → node test/inspect.js测试有 290 项断言,分 10 节。几处刻意的做法:
- 测试起真实子进程,不 mock
child_process。test/fixtures/里有两个真实服务器:good-server.js是符合规范的实现(可用 argv 切换:--tools N、--paginate N、--instructions、--poisoned、--no-description、--no-schema、--no-annotations);broken-server.js有 8 种故意坏法(silent/crash/garbage/banner/empty/noresult/stagger/partial)。 - 不变量式断言。 比如「每条 finding 都必须带解释」「分数必须等于 60 加权重和」「没跑探测时不得出现运行时 finding」「报告必须无条件打印第三节」「直接启动的服务器不得被授予它没付过的启动器折扣」「崩溃的服务器既不该得到计时加分、也不该被额外判处计时减分」。这类断言抓到的 bug 比逐条对输出的多。
partial模式把一帧拆成两次写,用来证明组帧逻辑不假设「一个 data 事件 = 一条消息」——本地这么假设能跑,有负载时会挂。- 5b 节专门锁死计时隔离。 它会回归失败如果:npm 启动的服务器因为别人的开销而丢分、直接启动与经 npm 启动的同一服务器分数不一致、被扣减后的serverMs为负或超过墙钟、单次采样的临界值贸然给分、或者采样次数没有被如实标注。- 断言一个现象,就必须把那个现象放进数据里。 这里犯过一次:测「维护端脚本只扣 1 分」时用替换而非追加构造
flags,顺手把两个正面信号(good.recent-release、good.multi-maintainer)也丢了,于是差值是 13 而不是 1——看起来像代码错了,实际是测试写错了。逐项打印权重才定位到。先怀疑代码、再怀疑测试期望,但两者都要真查。
零依赖 ESM + JSDoc 类型标注(// @ts-check)。
诚实的边界(再说一次)
mcp-server-inspector 是准入前的最低限度体检,不是安全审计,不是质量认证,不是代码审查。
- 通过 ≠ 安全。 只说明没有在握手和工具声明阶段观察到明显问题。
- 工具数量 ≠ 能力。 14 个工具不代表能做 14 件事,也不代表做得好。
- 活着 ≠ 好。 一个服务器能顺利握手,仍可能功能全错。
- 评分是启发式,不是测量。 权重是我们选的,公开可查、可手算、可以不同意。它排序「值得看一眼的程度」,不量化「安全性」。
这个工具的价值不在于给出一个分数,而在于把「我知道什么」和「我不知道什么」分开写清楚。如果这一点做到了,它就有用。
生态数据与出处
上面引用的生态规模,出处如下,可自行核对:
- MCP 服务器数量:138,000+,来自 MCP 官方 Registry(
registry.modelcontextprotocol.io)。官方modelcontextprotocol/servers仓库的第三方列表已被该 Registry 取代。 - 该数量靠人工筛选分类——2026 年的生态参考资料把「最近 90 天内有提交」这类判据写进收录标准。十几万个包靠人力筛,说明工具缺口是真实存在的,这也正是本项目立项的依据。
关于竞品形态的判断(例如 Apple/macOS 生态的饱和状况)属于调研期的观察,随时间会变,不作为本工具正确性的依据。
许可证
MIT
