给自己的机器做了一次 MCP / skills 资产盘点,想搞清楚一个问题:这机器上到底有多少能力在用?
四个小时之后我手里有四个数字。每一个看起来都像结论。
其中一个还被我当成”发现了一个真实泄露”发了出去。它不是。
这篇记录那几个数字是怎么错的,以及我为其中一个错误付了什么代价。
先说我不做什么
这次盘点有几件事是一开始就决定不做的,先划出来:
- 不公开那 4683 个查不到协议的 skill。 无声明不等于可自由使用,默认仍受著作权保护[19];
- 不把”我没查到它的许可证”记成”它没有许可证”——这两件事的差别就是能不能再分发[20];
- 不用任何占位符去糊弄空值。 查不到就写”未声明”,不写”MIT”也不留空。
这三条划下来,后面能公开的就只有 3308 个。它们里面的绝大部分同样是厂商内容, 但至少每一条的授权状态是可查的。
起点:一句含糊的问题
问题里没说”多少”该以什么为单位。配置文件?实际能调用的?已下载待启用的?还是这个资产本身该不该存在?
我没问,直接写了脚本数。
files_scanned=927 servers=910
910。这个数字很干净,很具体,我可以拿它写标题。
它是错的。
第一个数字:分子对了,分母错了
910 个 server 定义里,自己手写的配置只有 25 个。
剩下 866 个是各种 AI 客户端安装插件时自动落盘到缓存目录的第三方模板:
| 归属 | 数量 |
|---|---|
workbuddy-plugin | 276 |
workbuddy-connector | 260 |
workbuddy-marketplace | 243 |
codex-plugin | 44 |
workbuddy-ai-plugin | 22 |
| 其他 | 21 |
WorkBuddy 三个市场合计占 90%。
25 + 866 = 891,和 910 差 19 条——那 19 条来自无法归入任何一类的路径 (散落在聊天客户端缓存、下载目录里的零散配置)。不补这个差,就又要用一个 “凑得上的数”代替真实的数。
我拿”扫到了多少条定义”当”有多少能力在用”,相差 36 倍。[9][10]
如果按 910 估算”这个环境的 MCP 暴露面有多大”,结论会离谱到没法用——而 910 本身看不出任何问题。
补记(发布后被人核对才发现):这篇文章的第一版里,上面那张表我填的是
codex-plugin 1922 / workbuddy-plugin 1416 / workbuddy-marketplace 672 / codebuddy-marketplace 372。 这四个数是 skill 的来源分布,不是 MCP 的——我从另一个清单里直接抄了过来。方向恰好相反:那张表暗示 MCP 生态以 Codex 插件为主,真实主力是 WorkBuddy 的三个市场。
而且我在表下面加了一句”同一份配置有多个客户端副本,去重后 866”,这句话也是错的—— 那份 CSV 的 866 行已经是去重后的(
server+config_path的唯一组合数正好是 866), 不存在需要再去重的副本。这张表是用来论证”分子对了分母错了”的。而它本身就是分子分母搞错了。
第二个数字:整个方向漏了
第一轮只扫配置文件。扫完给自己下了个结论:
Docker 那边没问题——compose 里的环境变量用的都是
${VAR:-默认值}形式,没有硬编码密钥。
这句话的代理指标是”我读了 compose 文件”,真正该测的是”Docker 里到底有什么”。[4]
docker images --format '{{.Repository}}' | grep -c mcp
→ 234 个 MCP 相关镜像
docker images --format '{{.Repository}}\t{{.Size}}' | grep -i mcp \
| awk -F'\t' '{s+=$2} END {print s}'
→ 129.2 GB
docker ps -a --format '{{.Image}}' | grep -ci mcp
→ 0215 个来自官方 mcp/ 目录,19 个来自第三方 registry,占着 129.2 GB,
没有容器在跑,没有任何配置引用它们。
差的不是精度,是整整一个维度。我以为在做全面审计,实际只做了一个方向, 然后把”我做完的那部分没问题”写成了”这件事没问题”。
第三个数字:工具报的数,得自己验
扫 skill 时 find 报了 1778 个 SKILL.md。
去修 find -maxdepth 的限制时才发现实际是 8003。
find ~ -maxdepth 7 -iname SKILL.md → 1778
find ~ -iname SKILL.md → 8003
同样是”我跑了个命令,报了数,我就信了”。这次不是代理指标选错,
是工具的默认行为被当成了事实——-maxdepth 静默截断,不会告诉你”还有更多”。
这类错误最难发现,因为它不产生错误输出。命令返回 0,结果是一个列表,看起来完全正常。[11][12]
写完初稿之后有人来复核,当场在同一台机器上撞见同一类错误的另一个变种:
gh api .../git/trees?recursive=1 → { "truncated": true }GitHub 同样静默截断文件树。第一次拿到 6280 个
SKILL.md,第二次 56301, 两次都不是全量,返回码都是 0。这说明它不是”某个 find 用错了”,而是一整类陷阱:任何会省略掉一部分结果的工具, 默认都不会告诉你它省略了。
find -maxdepth、GitHub tree API、 甚至一个自己写的[:40]字符串截断,都是同一族。
顺带更正一个标题口径:标题里的 7991 是归档数量,8003 是扫描发现数量,
中间差的 12 个是噪声(散落在下载目录和聊天客户端缓存里的 SKILL.md,
其子目录恰好含有 skill)。两个数都对,但混用会让人以为文章算错了。
第四个数字:最安静的一个
有 4 个残留的 Docker 卷,名字都很像样:mcp-sqlite、mcp-task-data、claude-memory、
okta-mcp-server-keyring。
第一反应写进了给用户的回复:“顺手清掉能释放点空间。”
备份完、算完校验和之后才发现:
mcp-sqlite/db.sqlite 0 bytes
mcp-task-data/current-tasks.db 262144 bytes (28 张表里 27 张是 0 行)
claude-memory/index.js 16673 bytes (2025-04-30,一年多没动)
okta-mcp-server-keyring/ (空)
合计约 300 KB。
代理指标是”存在 4 个卷”,真正该测的是”这 4 个卷里有多少数据”。 前者是 4,后者接近 0。[5] 而这两个数字之间的差距,只有在真的去打开每个卷的时候才会出现。
这个形状和”PID 文件记的是包装进程”完全一致,方向还相反: PID 那条会删错东西,这条会让人以为省了 129 GB。
中场:四个形状并排
| 我报的 | 我用的代理指标 | 真正该测的 | 差多少 |
|---|---|---|---|
| 910 个 MCP server | 扫到了 910 条定义 | 其中多少是自己拥有的能力 | 36× |
| Docker 没问题 | 我读了 compose 文件 | Docker 里到底有什么 | 129.2 GB |
| 1778 个 skill | find 报了 1778 | 命令有没有静默截断 | 4.5× |
| 删卷能释放空间 | 存在 4 个卷 | 这 4 个卷里有多少数据 | 接近 0 |
共同形状:
用一个能拿到的表示,去代替一个同样能拿到的权威值;而”它俩差多少”这件事,一次都没量过。[4][5] 但这还不是 Goodhart[1][2][3][8]——本文的尺子从一开始就选错了,不是被当成目标之后才坏的。
注意”能拿到”这三个字。四个例子里权威值一直都可以取到:
get_me 就在工具清单里没调,端口一条查询的事,镜像引用就写在启动参数里。
把这件事说成”环境不支持只好退而求其次”,是给自己一个台阶——而台阶会让人不去查。
后记:这篇文章自己被抓了三次
上面四节写完就发布了。发布之后有人没读文章、直接把引用的那份归档拉下来逐个数字重算, 找出两处硬伤:
一,第一张来源表整个数据集用错了(见第一节补记)。二,许可证表少了一整类, 五行相加对不上总数,而每一行的百分比单独看都自洽。
两处都是硬伤,两处我都当场核对确认了是自己错。
值得单独记下来的是它们为什么没被我自己发现:
- 第一处:那张表的数字格式规整、量级合理、和上下文”看起来吻合”。没有任何局部特征会报警。
- 第二处:每一行都自洽,只有求和才会暴露。而我在写的时候根本没有求和——我写的是 四个我记得的类别,不是从一份完整分类表里抄的。
所以这不属于”我会犯的错”,而属于**“这类错我的自查方式抓不到”**。[13][9] 自查是对着目标值逐项核,而这两种错的症状是”每项都像对的”。
对这两处,有效的做法是同一条:让总数参与判定。 表必须能加回去,命令必须能在另一套口径下重跑, 数字必须能被一个没读过上下文的人独立复核。
第三次是在修订这一版时被抓到的,对象换成了参考文献。补引用时,检索方交回 24 条, 格式规整、出处齐全——其中两条经独立核实是编造的:一条把 arXiv 编号指错了地方 (https://arxiv.org/abs/2009.03529 实际是 Haffert 等人的天体物理仪器论文,不是软件度量论文), 一条把官方规则的编号和原文都记错了(写成 ASD-STE100 的 Rule 9.4,实际是 Rule 5.2)。
为什么这又是同一个形状:引用是”一个名字指向一个来源”。一条编造的引用,就是一个 指向空处的名字——它格式正确、序号连续、在正文里读起来毫无破绽,和”910 个 server” 是同一种东西。
怎么发现的:不是靠读,是靠独立取一次原始出处——打开那个 arXiv 编号、翻官方规则原文。 这和”如果重来”里的第二条是同一个动作。
中场之二:我把自己当成了泄露发现者
同一个形状,在我自己身上复发了一次,而且这次有代价。
排查脱敏扫描器的告警时,其中一条是:一条 issue 正文里的 Authorization 头长度 72,
头两个字符是 Be。我把它当成了真实泄露,并且在对话里断言了两次:
“确认是真漏洞""多抓出 982 处以前漏掉的带前缀凭据”
然后去核对源文件。原文是:
export TAVILY_API_KEY="tvly-YOUR_API_KEY_HERE"
是占位符。 而且脱敏日志里那条记录压根不存在——它从头到尾没被我的规则匹配过。
两个错误叠在一起:
- 我从掩码后的长度和前缀推出”这是真的”,没有回去读原文;
- 真正的缺陷确实存在——
\bapi_key\b匹配不到TAVILY_API_KEY里的API_KEY, 因为下划线是单词字符,所有带前缀的环境变量名都漏网了。
但这两件事没有关系。缺陷是真的,推断是假的,而我把推断当成了结论发出去。
要命的地方在于这条已经进了公开 commit,只能 amend 后 --force-with-lease 重推,
并把措辞改掉。代价不是技术上的,是”我说我发现了泄露,结果没有”这件事本身。
真正的教训不是”应该更谨慎”,而是:
代理指标不仅会出现在采集环节。它会出现在”我据此下结论”的那一步。 一个看起来像密钥的字符串,和一个真的是密钥的字符串,在掩码之后没有区别。[6][7]
中场之三:工具层的同一个错相
脱敏本身也反复栽在同一个地方。
第一次:66 处”密钥命中”,只有 1 处是真的。
原始:headers.Authorization = "Bearer ${VAR}" ← 引用环境变量
正则看到 Bearer 加一串字符就报。逐条核实后 66 减到 1。
第二次:纠偏过头,第一版规则报了 3585 处。
process.env.API_KEY
this.value
expires_in
全是代码标识符,不是凭据。全部替换成 ${SECRET} 之后 skill 直接不能用了——
归档的目的就是”后面还能直接用”,过度脱敏把这个目的消灭了。
收紧判定(要求值同时含字母和数字、且不是点号分隔的标识符链)之后落到 325 处真实凭据, 另有 79 处机器身份路径,合计 404 处。
第三次:校验器自己在喊狼来了。
泄漏校验脚本报了十几处”残留 token”,逐条看:
PublicKeyToken=b03f5f7f11d50a3a ← .NET 程序集强名,微软的公钥标记
Token=test-token-12345 ← 示例代码里的测试固件
一个只会喊”有威胁”的检测器,比没有检测器更糟,因为你会开始忽略它。 最后给校验器加了一份带理由的白名单:每一条都写清楚为什么它不可能是凭据。
中场之四:14 处路径穿越里,13 处是噪音
安全工具报了 14 条 high,我逐条看了。
13 条是:
with open(args.output, "w", encoding="utf-8") as f:
args.output 是操作者自己在命令行传的参数。写到用户指定的位置是 CLI 的正常行为,不是漏洞。
真的那一条在这里:
safe_name = panel_info.replace("/", "_").replace(":", "_").replace(" ", "_")[:40]
filename = f"panel_{pi}_{safe_name}.json"
filepath = os.path.join(output_dir, filename)
panel_info 来自腾讯云 API 返回的面板名——是远程数据。过滤器处理了 /、: 和空格,
漏了 \。Windows 上反斜杠是路径分隔符,一个名为 ..\..\..\x 的面板就能写出 output_dir 之外。
向量窄(需要能命名面板的权限),但确实存在,而它就藏在那 13 条噪音中间。
再往后:查不到的东西也是数据
最后一块是许可证。把这 7991 个 skill 的授权状态逐条查清、列成一份完整清单——这正是软件物料清单(SBOM)关注的”里面有什么”[23]。7991 个归档的 skill 里:
| 状态 | 数量 | 占比 |
|---|---|---|
| MIT | 2283 | 28.6% |
| Apache-2.0 | 469 | 5.9% |
上游明确标注 UNKNOWN | 384 | 4.8% |
| LICENSE 正文无法归类 | 107 | 1.3% |
| 明确专有 | 56 | 0.7% |
| AGPL | 6 | 0.1% |
| CC | 3 | 0.0% |
| 本地零证据 | 4683 | 58.6% |
有声明合计 3308,加零证据 4683,正好 7991。
这一张表的第一版只列了 MIT、Apache-2.0、专有、AGPL 四类,五类相加对不上总数—— 漏掉的正好是”上游明确写了 UNKNOWN”和”正文无法归类”这两类共 494 条。 而且每一行的百分比按 7991 反算都自洽,所以肉眼看每一行都没错,只有加起来才暴露。 这也是为什么它当时没被发现:错的不是某一格,是少了一行。
那个 4683 不是采集失败,是上游本身没有声明。最大的一个来源目录
(786 个连接器)里连 plugin.json 都没有,只有裸目录。24 个上游仓库里也有 6 个在 GitHub 上
没有 license 字段。
当时差一点就把这 4683 个算成”没问题的第三方内容”放过去了。 代理指标是”我没查到它的许可证”,真正该问的是”我有没有权力再分发它”。[21][22]
如果重来,我会先做哪一步
按今天撞出来的次数排序:
一,先划不做什么。 成本几乎为零,但它决定后面每一步的判断标准。 “不公开无声明内容”这一条如果在开头没写,后面就会在某个”反正大家都这么做”的时刻被绕过。
二,任何数字在写下来之前,先去看它到底在数什么。 不是复核,是去看。 910 要看归属、1778 要看命令的默认行为、4 个卷要打开、66 要读原文。
三,把”我据此下的结论”也当成采集环节来审。 这是这次最贵的一条—— 我漏掉的那次,不是采集错了,是从掩码长度推出结论那一步错了,而采集本身没错。
四,让总数参与判定。 一张表必须能加回去,一个数字必须能在另一套口径下重跑。[10] 自查是对着目标值逐项核,但本文第一节和倒数第二节这两种错,逐项核是抓不到的—— 它们的症状就是”每项都像对的”。
五,检测器会喊狼来了,要给它一份带理由的白名单。 一份白名单同时是文档: 它记录了”我们知道这些形状存在,它们不是威胁”,比多一份干净的报告有用。
语言这一层:一个名字承担了它不配承担的含义
上面四个错误,共同形状是”一个便宜、相关的指标,替代了真正想测的量”——而那个被替代的权威值,和这个指标一样是”能拿到”的[4][5]。但如果从语言的角度再看一遍,它们还有一层更统一的解释:
每一次,都是让一个名字,承担了它不配承担的含义。
- “910 个 MCP server” 里,“server” 这个词同时指向了”自己手写的配置”和”厂商自动落盘的模板”;
- “1778 个 skill” 里,那个数字指向的是”命令的输出”,而不是”磁盘上的事实”;
- “4 个卷” 里,“卷” 这个词暗示了”里面有数据”。
这几个名字单独看都没错,错的是它们被拉去覆盖了一个自己并不精确对应的东西。数字只是名字的量化形式,名字含糊,数字就一定含糊。
航空业在四十年前就撞上了同一件事,只是后果严重得多——维修手册里一个词有歧义,会出人命。于是他们没有选择”更小心地读手册”,而是主动给语言加约束:ASD-STE100,53 条写作规则分 9 节,词典规模按公开资料整理约为 900 个批准词的量级、约 1200 个需避免的词,一个概念只能有一个名字,一句话只能有一个操作[14][16]。
它最核心的一条是 Rule 1.11:
“Do not use different technical nouns for the same item.”[15]
而本文需要的,是它的镜像面:
不要让同一个名字,指向不同的东西。
这一条其实比原文更难。Rule 1.11 管的是”同物异名”,而这次四个错误里出问题的,全都是”同名异物”——“server""skill""卷”这些词,各自被拉去承担了本不属于它们的含义。ISO 704 把这件事说成一条更朴素的原则:一个概念对应一个指称[17]。受控语言这一整类实践,本质都是在替这一步做减法[18]。
后来 Karpathy 把这套四十年前的航空维修手册英语规范直接丢给 LLM 用。他的说法很轻:让模型按这套规范改写输出,会更清楚;他甚至只要求做到大约 80% 的 ASD-STE100[24]。再往后他更进一步——能画图就别写字,能生成 HTML 就别给静态文本,他把这些叫 “large, custom, discardable software artifacts”(大型、定制、用完就可以丢掉的软件产物)。他判断,随着 LLM 变强,人的工作会向上移动到监督、检查和理解[24]。
“人的工作向上移动”这句,本文其实已经用自己的代价演示过一遍了。这次最贵的一次错误不是采集错了,是从掩码长度推出结论那一步——采集没错,推断错了。而”如果重来”那一节的第三条正是同一件事:把”我据此下的结论”也当成采集环节来审。两句话说的是同一件事,只是那句在说”往上移一层”,这句在说”多审一层”。
还有一层也已经在本文里出现过。“discardable artifacts” 就是那些只为一个具体问题临时造的脚本——为了验证归档里没有残留凭据,我从源码里 harvest 出真实的凭据字面量,再断言这些具体的值没有出现在归档副本里。那个脚本不值得维护,也不该进任何长期资产清单。但它值得生成——因为贵的是”不这么做”。
尾声
这四个错误在当天看起来都很严谨。每一个背后都真跑了一个脚本、真读了一个文件、 真拿到了一个可以写进文章的数字。
所以普通的”检查一下”抓不住它们——因为检查本身用的就是同一个代理指标。
能让它们现形的只有一件事:
在报出结论之前,去看它到底在数什么。
这句话现在可以再往语言那一层推一格:不只是去看它在数什么,也是去看那个用来指它的名字,究竟指向了什么。名字不精确,数得再准也没用。
具体到这次:去看那 910 个定义里有多少是自己的;去看 docker images;
去看 find 会不会截断;去打开那 4 个卷;去读那条被掩码的原文到底是什么。
每一步都很便宜。贵的是”不这么做”——尤其是,当不这么做还能让你当场 宣布一个”发现”的时候。
附:口径与复核
本文所有数字的测量口径都写在正文里——扫了什么、怎么去重、按什么分类、 哪一步是人工判定、哪一步没有覆盖到(第三节末尾标注了 8003 与 7991 的口径差, 许可证一节给出了能加回去的合计行)。按这些口径,任何人都可以直接复算,不需要额外权限。
原始材料——脱敏日志、溯源清单,以及每一处被替换掉的凭据记录(只存长度和前缀指纹, 不存原文)——在 mcp-configs 仓库。
它保持私有,是刻意的选择,不是疏漏。开篇”先说我不做什么”里那条——不公开无声明内容、 无声明不等于可自由使用——落到这个仓库上,结论就是它不能公开:里面装的就是那些我 没有权限再分发的东西。公开这个仓库本身,就构成一次再分发。它是本文结论的兑现, 不是对结论的背叛。
代价要如实说:读者因此无法直接点进去复核原始材料。这是为了不违反授权而付出的代价, 我不打算把它藏起来。能补上的那半,是把口径全部写进正文——即使没有仓库访问权, 一个没读过上下文的人也能照着口径,在另一台机器上把每个数字重跑一遍,并当场质疑它。
这里还藏着本文那个形状的又一次复发:当初写下”点进去就能复核”的时候, 我把**“公开”当成了”可分发”**——而这两个词不是一回事。这又是一次”一个名字承担了 它不配承担的含义”:公开只说明内容可见,不说明我有权把它交出去。
校验脚本不做模式匹配就下结论——它从源码里 harvest 出真实的凭据字面量, 再断言这些具体的值没有出现在归档副本里。模式匹配只能证明”形状”没了, 证明不了”这些值”没了。
参考文献
- Goodhart, C. (1975). Problems of Monetary Management: The U.K. Experience. Papers in Monetary Economics, Reserve Bank of Australia.
- Strathern, M. (1997). ‘Improving ratings’: audit in the British University system. European Review, 5(3), 305-321.
- Campbell, D. T. (1979). Assessing the impact of planned social change. Evaluation and Program Planning, 2(1), 67-90.
- Yankelovich, D. (1971). Interpreting Marketing Research. Sales Management.
- Korzybski, A. (1933). Science and Sanity: An Introduction to Non-Aristotelian Systems and General Semantics.
- Amodei, D., Olah, C., Steinhardt, J., et al. (2016). Concrete Problems in AI Safety. https://arxiv.org/abs/1606.06565.
- Krakovna, V. (2020). Specification gaming: the flip side of AI ingenuity. DeepMind blog.
- Manheim, D. & Garrabrant, S. (2018). Categorizing Variants of Goodhart’s Law. https://arxiv.org/abs/1803.04585.
- Inozemtseva, L. & Holmes, R. (2014). Coverage Is Not Strongly Correlated with Test Suite Effectiveness. ICSE 2014.
- Forsgren, N., et al. (2021). The SPACE of Developer Productivity. ACM Queue, 19(1).
- Shore, J. (2004). Fail Fast. IEEE Software, 21(5), 21-25.
- Leveson, N. G. & Turner, C. S. (1993). An Investigation of the Therac-25 Accidents. IEEE Computer, 26(7), 18-41.
- Dijkstra, E. W. (1972). The Humble Programmer. Communications of the ACM, 15(10), 859-866. (EWD340)
- ASD Simplified Technical English Maintenance Group. (2025). ASD-STE100 Simplified Technical English, Issue 9. https://www.asd-ste100.org/assets/files/ASD-STE100_ISSUE9.pdf
- ASD-STE100, Rule 1.11: “Do not use different technical nouns for the same item.” https://www.asd-ste100.org/
- ASD-STE100, Rule 5.2: “Write only one instruction in each sentence, unless two or more actions occur at the same time.” https://www.asd-ste100.org/
- ISO 704 (2022) Terminology work — Principles and methods.
- Kuhn, T. (2014). A Survey and Classification of Controlled Natural Languages. Computational Linguistics, 40(1), 121-170.
- Berne Convention for the Protection of Literary and Artistic Works (1886, 最新修订 1979).
- Choose a License. No License. https://choosealicense.com/no-permission/
- Open Source Initiative. The Open Source Definition. https://opensource.org/osd
- ISO/IEC 5962 (2021) —— SPDX 规范.
- NTIA (2021). The Minimum Elements For a Software Bill of Materials (SBOM).
- Karpathy, A. (2025). On applying ASD-STE100 to LLM output. X (Twitter). https://x.com/karpathy/status/2105819303471976479