作者审计 MCP 工具目录时发现,一句批量生成的重复描述占用每次列表响应约 137.5 KB,删除后载荷减少 28.3%。案例说明,工具目录除了工具数量,还应检查描述文本的重复与信息价值。
本文反映的是 minia2a 在发布日期时的情况,保留于此以供查阅。当前模式:仅支持在 Base 上以 USDC 通过 x402 按次付费,每个已签名钱包可免费试用 5 次,无积分,也无充值渠道。
2026-10-16 · 作者 minia2a
MCP server 发布的所有文档中,tools/list 响应是被读取最多的一份。每个客户端都会在连接时获取它,模型也会阅读它,决定调用哪个工具、传入什么参数。人们关注这份响应时,大多在意你暴露了多少工具,几乎没人关心工具之间的那些字节究竟起了什么作用。
我们测量了自己的响应。每个会话的工具列表里,一句自动拼出来的话就占了 137.5 KB,却没有传递任何信息。
删除一句自动生成的话,就能从每次工具列表响应中移除这些内容——占整个响应的 28.3%。
只需一次未经身份验证的调用,不带支付请求头:
curl -s -X POST https://minia2a.uk/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
-o /tmp/tools.json -w '%{http_code} %{size_download}\n'
# 200 357167
修复前,3,815 个参数字段中有 3,814 个带有描述。每个描述都严格遵循同一个格式:
"description": "<that parameter's name> to process"
不是相似,而是除了字段名之外完全一致。board 的描述是“board to process”,state 的描述是“state to process”。
这一点值得在你自己的 server 上检查,因为它决定了该怎么修复。我们磁盘上的 schema 文件并不是一份 JSON Schema 文档,而是一张从服务 ID 到参数名列表的映射表:
{"x402-2048-move": ["board", "state"], "x402-abbreviation": ["text"], ...}
文件共有 1,624 个条目,任何地方都没有 description 字段。网关会把每个名称转换成一个参数 schema;由于 schema 的结构里要填一个描述字段,它就用参数名加上“to process”来填充。没有人写过这句话。是一条代码路径把它生成了 3,814 次,而没有任何审查者见过它——因为审查者会打开的文档,并不是客户端收到的文档。
如果你的描述是根据字段名生成的,那你拥有的就不是描述,而是一笔成本。
我们没有保存修复前的响应,所以按它在实际响应中的 JSON 编码形式,精确重建了被删除的片段——每个参数字段对应一段 ,"description":"<参数名> to process":
140,842 bytes = 137.5 KB across 3,815 slots
357,167 + 140,842 = 498,009 bytes = 486.3 KB counterfactual
修复前的一次独立测量结果是 482 KB。重建结果比它高出 0.9%——这个差距足够小,说明上述计算模型,也就是为每个参数字段追加一个描述键,显然已经解释了全部开销。差异来自格式,而不是漏算了什么。
参数描述是工具目录承载语义的地方。board 这个名称无法告诉模型预期的格式、取值范围,也无法说明它究竟是整数列表,还是表示一连串走法的字符串。真正的描述,应该提供读者无法仅从名称推导出来的信息——判断标准就这么简单。按这个标准,我们有 3,815 个字段没有语义信息,却带着 137.5 KB 的噪声,而且这份文档会在每个会话开始时被获取。
这类缺陷有两个特点,导致它很容易上线,却很难被发现:
description 是可选的。用废话填充它完全合法,没有任何机制会拒绝。代码审查同样看不见它,因为被审查的产物,也就是 schema 文件,并不包含客户端收到的那句话。
下面这段脚本适用于任何支持 streamable HTTP 的 MCP server。它会获取 tools/list,然后报告两件事:你的参数描述中有多少只是在复述字段名,以及这些复述消耗了多少字节。
python3 - <<'PY'
import json, re, urllib.request
req = urllib.request.Request("https://YOUR-SERVER/mcp",
data=json.dumps({"jsonrpc":"2.0","id":1,"method":"tools/list"}).encode(),
headers={"content-type":"application/json"})
d = json.load(urllib.request.urlopen(req))
tools = d["result"]["tools"]
params = restated = cost = 0
for t in tools:
for name, p in ((t.get("inputSchema") or {}).get("properties") or {}).items():
params += 1
desc = p.get("description") if isinstance(p, dict) else None
if not isinstance(desc, str): continue
# a description that is a function of the field name is not a description
if name.lower().replace("_"," ") in desc.lower() and len(desc) < len(name)+30:
restated += 1
cost += len(',"description":') + len(json.dumps(desc))
print(f"{len(tools)} tools, {params} params, {restated} restate the name "
f"({100*restated/max(params,1):.1f}%), {cost/1024:.1f} KB")
PY
对于修复前的目录,脚本会报告 3,814 / 3,815 个描述在复述字段名,四舍五入后为 100%,占用 137.5 KB。对于当前目录,它报告的数量是 0,大小是 0.0 KB,因为这些字段现在只包含名称和类型,除此之外什么都没有。从信息量上说,它们之前承载的也恰好只有这些。区别在于,客户端为了获知这些信息,需要付出多少成本。
一个很容易想到的应对办法是:“生成更好的描述。”请克制住这个念头。生成过糟糕句子的生成器,只会换一种风格继续生成糟糕的句子:问题的根源在于,它的输入只有字段名,而一个只接收字段名的生成器,不可能生成读者无法自行推导出来的信息。要么让理解参数含义的人或模型在能够掌握这些含义的地方写下描述,要么干脆不输出描述。两种做法都可以。填充废话不行,因为它的成本和真正的内容一样,用 grep 检索时,看起来也和真正的内容一样。
方法说明:上文中的响应大小、工具数量、参数数量和描述数量,均来自 2026-10-16 的同一次线上 tools/list 调用,可以用第一条命令复现。137.5 KB 是对被删除片段的重建结果,并非保存下来的修复前测量值;修复前的 482 KB 来自一次独立测量。这里明确说明两者相差 0.9%,是为了让你自行权衡这两项证据。
本文最初发表于 minia2a.uk。
如需进一步采取行动,你可以考虑屏蔽此人和/或举报滥用行为。