AI Agent 工具系统进阶:输出预算、渐进式披露与 MCP

目标:吃透工具系统边界(输出预算 / 渐进披露),写出第一个能被外部调用的自定义 MCP Server。

工具系统(s02)

加一个工具,要同时改两个地方——给模型看的 TOOLS 声明数组,和真正干活的 if-else 执行分支。工具一多,两处早晚对不上:声明了工具却忘了写分支,模型兴冲冲调用,只得到一句"未知工具"。

另一个问题出在改文件上:shell 理论上万能,但让模型拼 sed -i 's/old/new/' file 去改文件很不可靠——引号转义、正则特殊字符、跨平台差异(Windows 没有 sed)、多行文本,每一项都容易出错。

解决方案

解法是把工具收拢成一张注册表——说明和实现放在同一处,天然不会失配。这就是单一事实来源(single source of truth):同一份信息只写在一个地方,其余都从它生成。从此加工具 = 加一个条目。真实产品都是这个形状——Claude Code、Codex、Reina,本质都是一张注册表。

改文件的可靠性问题,靠读、写、改文件的专用工具解决。专用文件工具不是为了省事,是为了可靠。

实现:

agent.mjs

工具注册表

注册表就是一个对象,每个工具一个条目,说明(description + parameters)和实现(handler)放一起:

API 需要的 TOOLS 数组不再手写,由注册表生成(单一事实来源);调度也从 if-else 变成查表 dispatch(call):

dispatch 把 s01 的"错误即信息"升级成了系统性约定:未知工具、坏参数、handler 抛异常,三条失败路径全部变成文本回给模型,任何一条都不打死进程。主循环唯一的变化是把 if-else 换成 dispatch(call),之后不再修改。

edit_file:唯一匹配规则

这条规则把"模型脑中的文件"和"磁盘上的文件"强制对齐:

  • 匹配不到 → 模型的记忆过期了(文件被改过,或它记错了)→ 报错引导它先 read_file 刷新认知,而不是改错地方;
  • 匹配到多处 → 定位有歧义 → 报错引导它带上更多上下文行,精确到唯一。

报错是写给模型看的界面:好的报错直接告诉模型下一步动作,模型照做就能自愈;坏的报错(“Error: -1”)只会让它原地打转。

运行:

两个实验:

  1. 工具组合:对它说"在 demo/ 下建一个 hello.js 打印当前时间,然后把打印内容改成中文,最后跑给我看"。你会看到 write → edit → $ node 三种黄色行依次出现——一条指令,模型自己编排了三种工具。
  2. 触发唯一性检查:挑一个文件里出现多次的短语让它替换。观察 edit_file 报"出现多次"、模型带上更长的上下文重试成功——这是唯一匹配规则的现场演示。

练习

加一个 list_dir 工具:列出目录内容,标注类型(文件/目录)和大小。

这次你只需要加一个条目。对比 s01 挑战题的体验,可以体会注册表的价值,也是"机制围着循环长,循环不变"的第一次兑现。

练习的答案

在 REGISTRY 里加一个条目(文件顶部 import 补 readdirSync, statSync):

要点:加一个工具 = 加一个条目,TOOLS 生成和 dispatch 查表都是自动的,主循环一行不改——这就是注册表兑现的"开闭":对扩展开放(加条目),对修改封闭(循环不动)。

与真实产品对照(延伸阅读)

  • Claude Code 的 Edit 工具与本章 edit_file 规则相同(还多一个 replace_all 参数处理"全部替换"的场景,留作思考题)。
  • 为什么用字面量匹配而不用正则替换?因为正则转义是模型的常见出错点。字面量匹配 + 唯一性规则比正则可靠,这是各家产品从生产事故中得出的共同结论。
  • Reina 的工具注册表在 packages/tools/src/,每个工具一个文件,形态和本章 REGISTRY 一致;它的 CLAUDE.md 里有一条规则:“加新工具要注册进 tool registry,不许给引擎类加方法”——注册表一旦建立,就要守住它。
  • CRLF 问题:Windows 文件是 \r\n 换行,模型给的 old_string 是 \n,会匹配不上。真实产品都遇到过。最简单的对策是让报错引导模型重试;更彻底的做法留作思考题。

工具输出预算与溢出(s04)

你让 agent 检查一个导出文件,它顺手执行了 cat data.json——一个 2MB 的文件。轻则这轮请求直接失败:2MB 约等于五十多万 token,超出模型一次能读的上限(上下文窗口),API 返回 400;就算窗口装得下,这五十万 token 也从此留在对话历史里,每一轮都重新计费——一次 cat,成本翻倍。

s02 给 read_file 加过一个 50KB 截断。但截掉的部分模型永远看不到,它也不知道自己错过了什么——如果问题恰好在第 51KB,就查不出来了。这三种坏结局(撑爆窗口、持续计费、截断丢信息)的共同根源是:模型每轮能看到多少,完全没有预算约束。

解决方案

给工具输出设预算,超出的部分完整存到磁盘(落盘),对话里只留一张"取件条"——写明存在哪、有多大、怎么取——不丢任何信息。解法不是截得更狠。截断和溢出是两种思路:

截断溢出
超限的部分去哪了删除——永远消失落盘——完整保存
模型知道自己错过了什么吗不知道知道:指针写明全文在哪、有多大、怎么取
需要细节时只能重跑命令(贵,还可能不可重现)read_file 分段取回

实现:

spill.mjs

① 指针的三要素:在哪、多大、怎么取

溢出后回给模型的不是一句"(已截断)",而是头尾节选加一条指针(那张取件条):

三个要素缺一不可:在哪(路径),多大(模型据此决定要不要读、分几段读),怎么取(read_file + offset/limit,外加一句"不要重跑命令"——没有这句,模型的第一反应往往是把 cat 再跑一遍)。

② 预算要两层:单条上限 + 整轮总量

只设"单条不超过 50KB"防不住:模型一轮发起十个工具调用,每条 30KB,单条都不超限,合计 300KB 照样超窗。所以预算是两层的,溢出从最大的一条开始(largest-first):

从最大的开始效率最高:溢出一条 60KB 通常就能让整轮回到预算内,剩下三条 30KB 原文保留,细节仍在上下文里。达标就停——预算的目的是保住窗口,不是压缩所有输出。

③ read_file 是特例:文件本身就是指针

enforceTurnBudget 跳过了 spillable: false 的条目。read_file 读出来的内容本来就在磁盘上,再落盘一份副本是浪费。它的正确形状是自我设限——用 offset/limit 分段读取,尾注告诉模型总量和续读位置:

同一个思想的两种形态:内存里的大输出 → 落盘 + 指针;磁盘上的大文件 → 指针就是它自己。所有大内容最终收敛到同一个动作:read_file 分段读取。

④ 日志先压缩,再计预算

测试/构建日志是大输出里最常见的一种,其中大部分是噪声。所以在预算之前先做一道按重要性的压缩(anchor 保留 + trace 整块 + noise 折叠)。两条保险规则:行数太少不处理;省不到 15% 就原样返回。压缩会不会丢信息?不会——折叠真的发生时,全文同样落盘、同样给指针。压缩决定"回给模型多少",落盘保证"完整保存"。

运行:

免 key 演示:

三个场景,真实输出节选:

有 key 的话跑 node .\03-工具系统与MCP\s04_output_budget\agent.mjs,让它 cat 一个大文件——你会看到紫色的 ⤵ 溢出 行,然后模型拿着指针自己去 read_file 取细节。

练习

  1. 现在的 excerpt 固定"头部为主、尾部 200 字符"。对失败的测试日志这个比例是错的——总结和 exit code 在尾部。给 spillOne 加一个 mode: "head" | "tail" 参数,让 run_shell 的失败输出保尾部。判定"该保哪头"的信号已经在 records 里了(提示:status)。

  2. 一场长会话会在 .agent-spill/ 里积累几十个文件,谁来删?设计一个清理策略并想清楚什么时候删是安全的——指针还留在 messages 历史里时删掉文件,模型按指针去读就会失败。(这个问题在 s06 会更突出:压缩历史时,指针是保还是弃?)

练习 1 的答案:excerpt 的 head/tail 模式

要点:信号就在 record.status 里。成功输出的头部是"这是什么",失败输出的尾部是"为什么失败"——模式跟着状态走,不需要模型自己猜。这也呼应 Reina 的工具层默认"shell 输出保留尾部"。

练习 2 的答案:.agent-spill/ 清理策略

安全条件:只有"没有任何现存消息还引用该文件"时才可删。指针还在 messages 历史里时删文件,模型按指针去读就失败。

指针怎么找:从 tool 消息内容里用正则提取——/已完整保存到 ([^。\s\]]+)/。

三个安全时机:

时机为什么安全说明
压缩时(主时机,s06 联动)压缩把含指针的 tool 消息压进中段 → 那些文件不再被引用压缩成功后,把被压中段里提取到的 spill 文件删掉
会话关闭时退出前扫一遍现存 messages 的引用,删掉未被引用的兜底,覆盖没被压缩的会话
引用计数 + 延迟维护 Set 记录被引用的文件;不再被引用且存在超过 N 天才删防误删的最后防线

绝对不要:单纯按"文件年龄"删——一个 30 天前的指针可能还在未压缩的长历史里,按年龄删就会让模型读不到。

与真实产品对照(延伸阅读)

本章是 Reina 输出管线的简化版,生产实现分两层:

  • 工具层(packages/tools/src/utils.ts):每个内置工具的输出上限 50KB / 2000 行;shell 输出保留尾部(exit code 和失败总结都在结尾),全文落盘到 .reina/tool_outputs/ 并附 read_file 指针。
  • 引擎层(packages/core/src/engine.ts 的 enforceTurnObservationBudget):每个请求前跑一遍整轮观测预算——单条超 100,000 字符、或整轮总量超 200,000 字符时 largest-first 溢出;替换成头尾节选 + .reina/tool_outputs/<callId>.txt 指针。这层聚合预算有一个很现实的动机:MCP(接入外部工具的协议)这类外部工具的输出不经过你的单条截断——外部工具不受你控制,聚合预算是它们唯一的兜底。另有一个 24,000 字符的紧急裁剪,只在请求即将超出窗口时启用——那是有损的最后手段,但它也带指针,不留死路。

“read_file 不落盘副本"这条特例是踩过坑的:Reina 早期版本给 read_file 的输出也落盘了一份副本,纯属浪费,后来修掉了。

日志压缩在 packages/tools/src/log-compress.ts,比本章多一个细节:把行里的数字/十六进制地址归一化后,相邻近似重复的行折叠成 (line) ×N。真实 vitest 输出实测省约 65%;eslint 输出则是 safe no-op——省不到 15% 阈值,原样返回。REINA_LOG_COMPRESS=0 可整体关闭。

Claude Code 的同类行为:Bash 工具输出超过 30,000 字符会截断——超长命令后看到的 “output truncated” 就是它的观测预算在工作。


渐进式工具披露(s15)

工具只有三五个时一切正常;接上 MCP、工具涨到几十个之后,每轮请求都要把全部工具定义(名字 + 描述 + JSON schema)原样发一遍,几千 token 轮轮都付。更隐蔽的是:工具数组一变,prompt 缓存整段失效,账单不降反升。

第一笔账好懂:没用到的工具定义,每轮白付。第二笔来自 s07(缓存命中工程):tools 数组和 system 一起位于缓存前缀的最前部(Anthropic 的序列化顺序是 tools 在前),数组一变,前缀从头失效,后面全部按全价重算。

解决方案

自然的想法:不常用的工具先藏起来(标成 deferred),只放一个 search_tool 入口;模型需要时按关键词搜,搜到后再放开给模型用——这个"放开"的动作,下文称解蔽。冷启动的 tools 数组因此明显变小。但解蔽这个动作如果实现不当,会反过来砸掉缓存:把搜到的工具加回 tools 数组,每解蔽一次,数组变一次,缓存失效一次。正确做法是数组恒定——搜到的工具永不加回数组,schema 走搜索结果文本,调用走常驻的代理工具。

实现:

demo.mjs

① deferred 目录:冷启动只放入口,不放全部工具

工具分两类:direct(每轮都进数组,比如 run_shell / read_file / search_tool)和 deferred(冷启动隐藏)。deferred 工具只把"名字 + 一句话摘要"放进一个目录,供 search_tool 检索。模型看到的冷启动 tools 数组因此小而稳定。

② 解蔽不能把工具加回数组

最直觉的解蔽实现:模型搜到 notify_user,就把它的完整定义追加进 tools 数组,下一轮直接调用。问题正在这:每解蔽一次,数组变一次,缓存失效一次(演示的坏做法)。省下的是冷启动的一次性 token,赔进去的是会话中段一次次前缀失效。

正确做法是数组恒定:搜到的工具永不加回数组。它的 schema 通过搜索结果文本交给模型——搜索结果是一条消息,位于缓存前缀的尾部,往尾部追加天然安全;实际调用走一个常驻的代理工具 run_tool({ name, input })。于是不管解蔽多少工具,发给服务商的 tools 块每轮字节恒定(演示的好做法,0 次失效)。

③ 硬校验协议:“永不回灌"的例外

②的代理方案对本节的坑天然免疫——代理本身常驻数组,被调的永远是它。但很多实现(Claude Code、Reina)不走代理:模型直接喊出被搜到的工具名,引擎在派发层认名字。这在 OpenAI 系协议没问题——tool call 的名字是自由文本,服务端不管你调的名字在不在数组里;Anthropic Messages API 却会硬校验 tool_use.name 必须在本次请求的 tools 数组里。同一套"永不回灌 + 直调派发”,换个协议就变成:模型通过 search_tool 搜得到工具、目录里看得见,每次直调却被 API 硬拒——工具"看得见摸不着”。这个坑格外隐蔽,因为缓存指标是完美的(数组零击穿),坏掉的是功能本身。

解法是按协议分叉:自由文本协议保持"永不回灌"的零成本快路径;硬校验协议把已解蔽名单回灌进数组(名单排序保证字节确定性)。代价是每解蔽一个工具付一次 miss——但名单落地后不再变化,前缀立刻重新稳定,这是一次性成本,不是坏做法那种每轮击穿。

这也回扣 s14(Provider 兼容层)的主旨:同一个客户端优化,能不能成立取决于协议语义。做披露设计时先回答一个问题——你的 provider 校不校验工具名?

④ 第三档:条件原生提升(能力即数据)

direct / deferred 之外还有第三档:某个会话级事实保证工具必然可用时,冷启动直接入列。典型例子:订阅制 provider 附带的服务端工具(如 Kimi 订阅自带的 kimi_web_search)。对挂着该订阅的会话,工具百分之百能调通,还让模型跑一次 search_tool 往返纯属浪费;对其他会话它又是死重(没有凭证,调了必失败),必须留在 deferred。同一个工具,两档可见性,按会话切。

两个关键,缺一不可:

  • 判据必须会话内稳定。订阅身份整个会话不变,所以提升后的数组每轮字节恒定,缓存零损耗。反过来,把提升挂在一个会话中途会翻转的判据上(比如"后台任务运行中"才提升生命周期工具),判据每翻转一次数组变一次,每次都是一记 miss。
  • 提升是声明式的。厂商知识不进引擎:由订阅解析层在模型配置上声明 subscriptionTools: ["kimi_web_search", ...],引擎只执行一条通用规则"配置声明什么就提升什么"。这和 s14 里"能力写进 models.json 而不是 baked-in 正则"是同一条纪律:能力是数据,不是代码分支。

运行:

免 key 演示:

对比同一件事的两种实现,直接算出 tools 数组的字节稳定性(真实运行输出):

注意坏做法那行"复用 99%“不代表只损失 1%:新工具追加在尾部,前面 99% 的字节确实没变,但 tools 块是一个闭合的整体,末尾一变,服务商就视为整块变更,tools + system + 全部历史一起按全价重算。差一个字节,等于整块失效。

练习

  1. 给演示的"好做法"接一个真实约束:run_tool 调用 deferred 工具时,权限要按目标工具裁决(复用 s13)。写一版 run_tool 的派发:run_tool({name:"delete_all"}) 应触发 delete_all 的 deny/ask,而不是 run_tool 自己的。思考如果漏了这步,会留下多大的漏洞。

  2. 阈值门控:工具总数少(比如 < 10)时,全 direct 反而更好——省掉一次 search_tool 往返的延迟。给披露加一个 auto:N:总数 ≤ N 就不 defer,> N 才进披露模式。N 该按什么标定?(提示:权衡"多一次搜索往返的延迟"和"多几千 token 的冷启动"哪个代价更高。)

  3. 按协议分叉(复用③):给 demo 写一个 resolveTools(protocol, unmaskedNames)——"free-text" 协议永不回灌,"strict" 协议把已解蔽名单排序后回灌。用 demo 里的前缀复用计算验证:strict 路径每解蔽一个工具恰好付一次击穿,此后回到 100% 复用。再想一步:如果模型在同一轮里解蔽了两个工具,回灌应该发生几次?(提示:名单是按轮落地的,不是按工具。)

练习 1 的答案:run_tool 按目标工具裁决权限

漏掉这步的漏洞有多大:run_tool 常驻 direct 数组,权限系统很可能给它开白名单(它只是个转发器,看起来人畜无害)。如果权限只查 run_tool 本身,那么任何 deferred 危险工具都能通过 run_tool({name:"delete_all"}) 绕过 delete_all 的 deny/ask 直接执行——代理变成了通用权限后门。所以"按目标工具裁决"不是优化,是必须。

练习 2 的答案:auto:N 阈值门控

N 怎么标定:看"多一次搜索往返的延迟"和"多几千 token 的冷启动"哪个代价更高。粗算:每个工具定义约 100300 token,全量 ≈ N × 平均定义长度。全量每轮成本 < 一次搜索往返的代价时,全 direct 更优;反之时披露更优。经验默认 N ≈ 1015,精确值应实测(量工具定义的总 token 数 × 预计轮数)。注意 N 判断的是总工具数,不是 deferred 数。

练习 3 的答案:resolveTools 按协议分叉

回灌发生几次:名单是按轮落地的,不是按工具。同一轮解蔽两个工具 → 两个名字同时进 unmaskedNames → resolveTools 一次返回新数组 → 恰好 1 次击穿,然后前缀稳定。如果两个工具分两轮解蔽 → 2 次击穿。所以"回灌次数 = 解蔽发生的轮次数”。

与真实产品对照(延伸阅读)

为什么不能照抄 Codex?Codex 能让模型直接调用命名空间工具名、而客户端数组不增长,靠的是 OpenAI Responses API 的服务端工具管理——那是 provider 专有能力,无法迁移到 Anthropic 以及绝大多数兼容后端。由此一条通用经验:参考某个 agent 的机制之前,先确认该机制在你的 provider 上是否存在。照搬一个不可迁移的能力,比不搬更糟。

Reina 的这套骨架在 packages/tools/src/registry.ts(isDeferredByDefault 默认白名单、deferredToolDescriptors / directToolDescriptors 分桶、resolveExposedTools 每轮按已解蔽集合重建数组)和 search-tool.ts(检索)。检索排序用了 TF-IDF cosine + 关键词混合,并加了 CJK 分词(中日韩按字 unigram + bigram),比 Codex 那套"中文拼音化再 BM25"精度高。但边界也很明确:词法检索跨不了语言(中文 query 搜不到英文工具),这是本质限制,Codex / Claude Code 至今也没上向量检索;对 agent 而言影响不大,因为它看到的工具目录本就是英文的,自然用英文搜索。

Reina 现已落地"数组恒定"(内部叫 Phase 1.3):解蔽只记进会话状态(activeDeferredTools),tools 数组每轮字节恒定;调用走直调派发(resolveOrUnmaskDescriptor 按名字找回描述符,turn 内局部生效),没有代理工具。也正因为走直调,③的坑是在真实接入 Kimi 订阅时踩出来的——OpenAI 系模型一直没事,换到 Anthropic 兼容端点当场"看得见摸不着",修复就是③说的按协议回灌已解蔽名单;④的声明式 subscriptionTools 也是同一次接入落地的。Claude Code 的可观察行为思路一致:核心工具常驻,MCP 等大批量工具标记为 deferred、只露名字;模型先调 ToolSearch 按需检索,schema 通过搜索结果进入上下文(缓存前缀的尾部,天然安全)——冷启动数组小而稳定,即①+②的组合。


MCP:动态挂载外部工具

到现在,agent 的工具全写死在注册表里——想加个新工具就得改源码。MCP(Model Context Protocol)是 Anthropic 发布的开放协议,用于连接 AI 助手与外部工具:声明一个服务器地址,就能把数据库、Slack、GitHub 这些服务的工具接进来,不动一行 agent 代码。

核心思路:spawn 子进程 → JSON-RPC 握手 → 发现工具 → 前缀注册 → 透明路由。对 Agent Loop 来说,MCP 工具和内置工具没有区别——都是名字 + schema + 执行函数。

解决方案

做一个最小的 MCP 客户端:通过 JSON-RPC over stdio 跟服务器握手、问它有哪些工具、把模型的调用转发过去、再把结果拿回来。传输用 stdio 的零配置优势:不需要端口管理、不需要发现服务、进程生命周期自动绑定到父进程。

实现:

配置格式

用户只需在配置文件中声明 MCP 服务器,Agent 会在首次 chat 时自动连接并注册它们的工具:

也可以使用项目根目录的 .mcp.json,格式相同。三处配置的服务器合并后一起连接,同名服务器后读覆盖先读。

最小 MCP 客户端(src/mcp.ts)

一个最小的 MCP Server(参考:mcp-demo-server.mjs)

MCP 服务器和客户端一样,也是 stdio 上走换行分隔的 JSON-RPC。给本地文件加一个只读搜索工具的练习,就是从这段骨架长出来的:

连接生命周期

MCP 协议的标准流程:

客户端侧三个关键状态:process(子进程句柄)、pending(请求-响应关联表:自增 id → Promise)、rl(readline 按行解析 JSON-RPC)。

关键设计决策

  • 为什么用 JSON-RPC over stdio 而不是 HTTP:stdio 零配置——不需要端口管理、不需要发现服务、进程生命周期自动绑定到父进程。子进程退出时所有 pending 请求自动 reject,不存在连接泄漏。
  • 为什么用三段式前缀名(mcp__server__tool):一个名字同时解决两个问题——避免冲突(不同服务器可能有同名工具)和嵌入路由信息(从名字直接提取服务器名,无需额外映射表)。Claude Code 用完全相同的命名方案。
  • 为什么 15 秒超时:MCP 服务器常用 npx 启动,首次运行需要下载 npm 包,通常需要 3-8 秒。15 秒足够覆盖大多数情况,但不至于让用户等太久。超时后静默跳过该服务器,Agent 继续用其他可用工具工作。
  • 为什么懒连接(首次 chat 时而非启动时):用户可能启动 Agent 只是想问一句"这个函数是什么意思",根本用不到 MCP 工具。懒连接让这种场景零开销。代价是第一次需要 MCP 工具时会有几秒延迟,但只发生一次。
  • 为什么不用 MCP SDK:直接用原始 JSON-RPC 有两个好处——零依赖(不增加包体积)和教学价值(读者能看到协议的完整细节)。整个 JSON-RPC 通信只有 ~60 行代码,足够简单。
  • 为什么 callTool 只取 type: "text" 的内容:MCP 返回 { content: [{ type: "text", text: "..." }, ...] },图片等其他类型暂不处理。
  • 失败不崩溃:MCP 连接失败只输出日志,Agent 继续用内置工具工作;一个服务器失败不影响其他。

运行:

跑通文档自带的演示(无需 API key,本地 mock 模型调一个来自外部 MCP 服务器的 add 工具):

输出:

练习 / 实践(对应 README 检查点)

  1. 跑通 learn-agent s04(工具输出预算——理解大输出为何会爆、怎么截断)。
  2. 照着 MCP 文档写一个自定义 MCP Server(比如给本地文件加一个只读搜索工具),放在本目录。
  3. 用 Claude Code 或 Codex 成功调用它——检查点:独立写一个 MCP Server,被 Claude Code 成功调用。

实践产物:mcp/file-search-server.mjs(已写好)

一个只读的文件搜索 MCP Server,提供 search_files({ query, dir? }):在根目录下递归按文件名/路径搜索(只读,不返回文件内容)。骨架和 mcp-demo-server.mjs 相同——stdio 上换行分隔的 JSON-RPC,零依赖。

单独测试(不经过任何 agent):

上面三步我已经实跑验证过:握手 ✓、tools/list 返回 search_files ✓、search_files(“mcp”) 找出了 mcp 目录下的文件 ✓。

我要做什么(这一章的检查点必须你自己完成)

第 3 步(被 Claude Code / Codex 调用)必须在你自己的环境做——需要你的 Claude Code 账号和终端,我做不了。步骤:

  1. 注册进 Claude Code(在 03-工具系统与MCP 目录下):

    或者不用命令行,直接写一个 .mcp.json 到项目根目录(Claude Code 会自动读):

  2. 在 Claude Code 里让它调用,比如:

    看它是否成功调用 mcp__file-search__search_files(黄色工具行)并返回路径。

  3. 如果没有 Claude Code 订阅,退而求其次:把 file-search-server.mjs 当普通 MCP server 用你自己的 agent(比如 Codex,或你前面的 v0~v2 agent 加一个 MCP 客户端)去连——检查点要求的是"被外部 agent 成功调用",载体可以换。

  4. 验收标准:你的 agent 能通过协议发现并调用 search_files,返回正确的文件路径列表——03 这一章就算闭环了。

与真实产品对照(延伸阅读)

维度Claude Codemini-claude(我们的实现)
MCP SDK@anthropic-ai/sdk 内置客户端原始 JSON-RPC(无 SDK 依赖)
服务器协议stdio + SSE仅 stdio
工具发现动态刷新(服务器可通知变更)一次性发现
配置来源settings.json + .mcp.json + 企业策略settings.json + .mcp.json
错误处理重试 + 降级静默跳过失败服务器
连接时机首次 chat 时懒加载首次 chat 时懒加载
子 Agent 支持独立 MCP 连接主 Agent 专属,子 Agent 不连接

Claude Code 的 MCP 实现要点:支持 stdio 和 SSE 两种传输、OAuth 认证、动态工具刷新(服务器可以通知客户端工具列表已变更);所有 MCP 工具以 mcp__serverName__toolName 格式注册——和我们的实现同构,只是多了传输、鉴权和热更新。

回到本章开头的问题——工具调用和 MCP 的区别是什么?什么时候该自己写工具而不是让模型猜? 答案的骨架是:

  • 自己写工具(注册表):工具是你的程序的一部分,和 agent 同进程、可复用其他模块、能访问内存状态。适合"agent 的器官"——读文件、跑命令、编辑代码。
  • MCP 工具(外部服务):工具属于别人,通过协议远程挂载。适合"agent 的感官/外设"——数据库、Slack、GitHub,以及你不想(或不能)写进 agent 的第三方能力。
  • 判断标准:工具和 agent 是否强耦合?强耦合(共享状态、依赖内部逻辑)→ 注册表;弱耦合(独立的领域能力,通过参数交流)→ MCP。MCP 的边界正是 s04 说的——外部工具不受你控制,聚合预算(输出预算)是它们唯一的兜底;s15 说的——工具一多,deferred + 检索披露是缓存的前提。