把 MCP server 挂进真实客户端
本文的每个数字都是跑出来的。 环境:Claude Code
2.1.258· Codex0.149.1· Nodev22.22.3· 被试模型claude-haiku-4-5-20251001。 验证代码在仓库experiments/mcp-in-clients/。 ⚠️ Codex 那一段按量计费,写作时只跑了 4 次,本文明确标注了它没测到的部分。
上一篇写了个六十行的 server,并用二十条断言 和六个故意写坏的版本查它合不合规。这一篇换个问题:把它挂进真实客户端之后, 你实际得到了什么、付出了什么。
三件事各有一个可判定的答案,而其中两件的答案和我动笔前的预期相反。
一、挂载方式:一个能完全隔离,一个不能
Claude Code 这侧有一对参数,实验能不能做干净全靠它:
claude -p --mcp-config ./mcp.json --strict-mcp-config '...'
--mcp-config 传入配置,--strict-mcp-config 忽略其他所有来源。
少了后面那个,你全局注册的 server 会一起挂上 —— 那么「工具数」就不是你设定的那个数,
下一节量 token 的实验会直接失效。
Codex 这侧用 -c 覆盖配置项:
codex exec --skip-git-repo-check \
-c 'mcp_servers.probe.command="node"' \
-c 'mcp_servers.probe.args=["/abs/path/server.mjs"]' '...'
但 -c 是叠加,不是替换,而且没有对应的 strict 开关。 实测每一轮里本机全局注册的
4 个 server 都会一起挂上,其中一个(指向 127.0.0.1:8080 的 HTTP server)
每轮都在日志里刷:
ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed
只能一个个手动关掉(-c mcp_servers.<name>.enabled=false)。
这个差异不只影响做实验。它意味着在 Codex 里你没有一个「只挂这一个 server」的干净作用域, 而调试 MCP 问题时,第一件想做的事恰恰就是把别的都摘掉。
二、工具定义根本没进上下文
stub 里这一节原本要讲「工具数量对上下文的占用,以及为什么不该一次挂十个 server」。 隐含的前提是挂多了会吃掉上下文。这个前提是错的。
先说观测手段,因为这一组的读数极容易读错。工具定义进的是每次请求的输入部分, 而 Claude Code 开着 prompt caching,同一份内容会被拆进三个字段:
input_tokens 10 ← 只看这个的话…
cache_creation_input_tokens 8907
cache_read_input_tokens 13557
input_tokens 恒等于 10,不管挂多少工具。只看它会得出「挂再多工具也不占上下文」——
一个漂亮、稳定、且完全错误的结论。 三者之和才是每次请求真实吃进去的量;
同一配置连跑几次,它的波动是 0 到 2 个 token,这就是本组的噪声底。
有了这个口径,工具数就能量了:
| 工具数 | 总输入(均值) | 相对 0 个 | 每个工具约 |
|---|---|---|---|
| 0 | 22431 | — | — |
| 1 | 22439 | +8 | 8.0 |
| 5 | 22474 | +43 | 8.6 |
| 10 | 22520 | +89 | 8.9 |
| 20 | 22610 | +179 | 8.9 |
干净的线性,每个工具约 9 个 token。
9 个 token 装不下一个工具。 我那个工具的描述是一句中文加两个带说明的 schema 属性, 怎么算都不止 9 个。所以要么我量错了,要么工具定义没有完整进去。
分辨这两者的办法很直接:把描述撑长,看增量跟不跟着涨。
| 每个工具塞进的填充词 | 10 个工具的增量 | 每个工具约 |
|---|---|---|
| 0 | +89 | 8.9 |
| 50 | +88 | 8.8 |
| 200 | +87 ~ +91 | ~9.0 |
200 个填充词乘 10 个工具,增量一动不动。 如果完整定义进了每次请求, 这一行该多出几千个 token。
结论是:客户端只把工具名放进每次请求,描述和 schema 是按需再拉的。
9 个 token 就是 mcp__probe__tool_01 这么一个名字的量。
所以「不该一次挂十个 server」如果有道理,理由不是上下文成本。 挂 20 个工具的常驻代价是 179 个 token —— 那是一句话的量级。
三、省下的,在真用它的那一刻还回去
上一节的结论很容易被读成「挂多少都无所谓」。但定义总得在某个时刻进上下文。 真去调用一次,看代价在哪显形 —— 两档都把描述撑到 200 词,每档 4 轮,全部确认真调用了:
| 挂载工具数 | 总输入 | 轮数 |
|---|---|---|
| 1 | 70903 | 3.0 |
| 20 | 71364 | 3.0 |
差值 461 个 token。其中约 171 个(19 个多出来的工具名 × 9)可以归因于名字本身, 余下的两百多是什么,我没有测量手段拆开 —— 合理的猜测是从更多候选里挑选的开销, 但那是猜测,不是这张表能证明的东西。
能确定的只有两点:代价确实存在,且它按「你用了什么」计费,不按「你挂了什么」计费。 挂着不用几乎免费;用起来的那一次要把定义拉进来。
这条比原来那个「不该挂太多」的建议有用得多,因为它可判定: 挂一堆很少用到的 server,代价接近于零;真正花钱的是高频调用的那几个。
四、判据:拒绝审批发生在 server 执行之前
这是全篇的判据,也是唯一一件涉及安全的事。stub 的原话:
「拒绝审批」必须在 server 执行之前发生。若之后才拦,这个工具就不能有副作用。
要测它,工具必须有个可观测的副作用。所以这一组换了个 server, 它唯一的工具在收到调用的第一时间就往磁盘 append 一行 —— 先于任何校验、先于返回:
case 'tools/call': {
// 🚨 副作用在这里,先于返回。
// 真实世界里「发邮件 / 删文件 / 下单」就长这样。
const note = params?.arguments?.note ?? '(no note)';
if (MARKER_FILE) appendFileSync(MARKER_FILE, `${note}\n`);
return result(id, { content: [{ type: 'text', text: `已写入:${note}` }] });
}
两个观测点必须交叉看,少一个就分不清结论相反的两种情况:
磁盘上那行在不在(server 执行了没有),以及 server 端有没有收到 tools/call
(客户端到底把请求发出去了没有)。只看前者的话,「拦在执行之前」和
「模型压根没想调用这个工具」长得一模一样。
每例 3 轮:
| 用例 | server 挂上 | 发出 tools/call |
server 执行 |
|---|---|---|---|
| 显式允许(基线) | 3/3 | 3/3 | 3/3 |
--disallowedTools |
3/3 | 0/3 | 0/3 |
PreToolUse hook exit 2 |
3/3 | 0/3 | 0/3 |
| 什么都不配(默认) | 3/3 | 0/3 | 0/3 |
判据成立。 三种拒绝路径下,server 端连 tools/call 都没收到 ——
拦截发生在请求发出之前。所以 MCP 工具可以有副作用,被拒绝的调用不会留下痕迹。
基线那一行不能省:它证明了后三行的「没执行」是被拦下来的,不是模型没动手。
但三种拒绝方式给模型的信息完全不同
这是顺带测出来的,而它有安全含义。同样是「不许调用」,会话里的反应差得很远:
--disallowedTools —— 模型看不见这个工具,于是开始找替代路径:
我没有找到 “write_marker” 工具。它不在我的可用工具列表中。我现在可用的写入工具有: Write — 写入文件内容 · Edit — 修改现有文件 · Bash — 可用
echo等命令追加内容
PreToolUse hook exit 2 —— 模型知道工具存在、知道被策略拦了,能复述理由,然后停手:
工具调用被您的安全策略阻止了。您的 hook 配置禁止了 write_marker 工具的运行, 显示消息:“Policy: this tool must never run. Do not try another tool.”
什么都不配 —— 走权限提示,等人批准(headless 下没人批准,于是停住):
Permission needed for write_marker. Awaiting your approval to proceed.
第一种的行为和上一篇 hooks 那篇测出的绕道
是同一回事:把工具从列表里拿掉,等于告诉模型「换个办法」。
如果你的意图是「这件事不能做」,而不是「这个工具不能用」,
--disallowedTools 反而在鼓励它绕道。
五、同一个 server,Codex 侧的工具没到模型
这一节本来该是「两个客户端各跑同一任务 10 次,比对成功率与报错文案」。 做不成,原因本身比原计划有意思。
同一个 server 挂进 Codex,握手完全正常。让 server 把完整往返记下来:
>> initialize protocolVersion "2025-06-18" clientInfo: codex-mcp-client 0.149.1
<< capabilities {tools:{}}
>> tools/list
<< [{name:"write_marker", inputSchema:{type:"object",...}}]
协议版本双方一致,schema 合法,Codex 把工具列表收下了。然后模型说它不存在:
The
write_markertool is not available in this environment.
问它能看见什么,列出来的是 list_mcp_resources、read_mcp_resource、
list_mcp_resource_templates —— 那是 MCP 的 resources,不是 tools。
具体的 write_marker 一个也没有。
排除掉的:协议版本不匹配、schema 不合法、本机全局 hooks 干扰、
以及一个名字看着极像元凶的 feature flag(tool_search_always_defer_mcp_tools,
处于 removed 阶段而生效值为 true)—— --disable 之后 token 数一字不差是 10136,
说明那个 flag 根本没生效,不是原因。
没排除的一条:命令行 -c 注册的 server,与写进 config.toml 持久注册的 server,
可能走不同代码路径。排除它要么动用户的全局配置,要么把中转 provider 的凭据
复制进临时目录 —— 这一轮主动放弃了,所以下面这句话是有边界的:
在 Claude Code
2.1.258里,MCP 工具会展开成mcp__<server>__<tool>直接交给模型; 在 Codex0.149.1里,用-c命令行注册的 stdio server 握手正常、工具列表被收下, 但没有出现在模型的工具列表里。后半句我没能定位到原因,也没有排除是配置问题。
写下来是因为它是这一篇最实际的一课:「server 写对了」和「模型能用它」是两件事, 中间隔着客户端。 上一篇用官方 SDK 客户端验证过协议往返全绿 —— 那证明了 server 合规,没证明任何一个具体客户端会把它接上去。
六、这一篇的判据
三个可判定的结论:
- 挂载成本按名字算,不按定义算。 每个工具约 9 个 token,与描述多长无关。 决定要不要挂一个 server 时,不用担心它的文档写得啰嗦。
- 调用成本按用量算。 挂 20 个不用几乎免费;真调用时定义要拉回来(实测差 461 token)。 该省的是高频调用的那几个 server,不是挂载数量。
- 拒绝审批拦在 server 执行之前,所以 MCP 工具可以有副作用。
但拒绝的方式要选对:把工具藏起来(
--disallowedTools)会让模型去找替代路径, 给出明确理由(hook + stderr)它才停手。
⚠️ 一个观测手段上的教训,比上面三条都通用: 判定「模型有没有真的调用工具」只能看 server 收到了什么。 第三节第一版的判据写成「会话文本里有没有出现工具的返回值」, 跑出 1/3 和 0/3 的成功率,那批 token 数据当场作废;改看 server 端 trace 之后是 4/4。 模型可能调用了却不复述,也可能没调用却说自己调用了。
本文没有回答的两个问题:
一是 stub 原本计划的跨客户端成功率对比(各跑 10 次比对成功率与报错文案)。 Codex 侧的工具没到模型,这个对比没有意义,所以没做 —— 而不是做了没写。 要补上它,先得解决第五节那个未定位的问题。
二是 HTTP transport 的 server。本篇两个 server 都是 stdio。 本机全局那个 HTTP server 每轮都报
Transport channel closed, 看起来 HTTP 那条路的失败形态和 stdio 很不一样,但那是别人的 server、 我没有它的日志,不能从一条错误消息推断出结论。