把 MCP server 挂进真实客户端

MCP 开发第 2 / 2 篇

本文的每个数字都是跑出来的。 环境:Claude Code 2.1.258 · Codex 0.149.1 · Node v22.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_marker tool 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> 直接交给模型; 在 Codex 0.149.1 里,用 -c 命令行注册的 stdio server 握手正常、工具列表被收下, 但没有出现在模型的工具列表里。后半句我没能定位到原因,也没有排除是配置问题。

写下来是因为它是这一篇最实际的一课:「server 写对了」和「模型能用它」是两件事, 中间隔着客户端。 上一篇用官方 SDK 客户端验证过协议往返全绿 —— 那证明了 server 合规,没证明任何一个具体客户端会把它接上去。

六、这一篇的判据

三个可判定的结论:

  1. 挂载成本按名字算,不按定义算。 每个工具约 9 个 token,与描述多长无关。 决定要不要挂一个 server 时,不用担心它的文档写得啰嗦。
  2. 调用成本按用量算。 挂 20 个不用几乎免费;真调用时定义要拉回来(实测差 461 token)。 该省的是高频调用的那几个 server,不是挂载数量。
  3. 拒绝审批拦在 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、 我没有它的日志,不能从一条错误消息推断出结论。