吃透 AI Agent 开发 · 第 13 篇 · 第三章 · Tool System
MCP 让远程能力有了统一插槽,但连接、超时、版本、权限和数据边界仍然要由宿主应用负责。
团队提出一个需求:“让 Agent 按公司的发布流程操作 Jira。”有人建议写 Skill,有人建议接 MCP,还有人想用 Hook 拦截所有命令。
这些方案都可能有用,但它们解决的不是同一层问题。扩展 Agent 前,先问清楚要增加的是知识、快捷模板、组织策略、本地执行能力,还是远程服务。
六种扩展各管一件事
| 机制 | 解决什么 | 会不会执行代码 | 例子 |
|---|---|---|---|
| Skill | 教 Agent 怎样完成一类工作 | 间接,通过现有工具 | 发布检查流程、代码评审方法 |
| Output Style | 约束回答的组织和语气 | 不会 | 教学体、事故报告格式 |
| User Command | 展开可复用 Prompt 模板 | 不会 | /review $file |
| Hook | 观察或干预生命周期 | 可以 | 部署前审批、工具结果脱敏 |
| Local Tool | 增加本机可执行能力 | 会 | 调内部 CLI、读专用文件格式 |
| MCP | 接入标准化远程工具服务 | 会 | Jira、数据库、浏览器服务 |
最容易混淆的是 Skill 和 Tool。Skill 告诉 Agent“应该怎样做”,Tool 让 Agent“真的能够做”。一份发布 Skill 可以要求先跑测试、再看 diff、最后创建版本;真正执行测试和创建版本仍靠工具。
用一个 Jira 需求走一遍
“按公司流程处理 Jira”可以拆成:
- 公司要求先读取工单、定位代码、给出方案,再等待批准:这是知识和流程,适合 Skill。
/fix-jira ABC-123自动展开固定请求:这是 User Command。- 禁止未经批准把评论发到 Jira:这是执行策略,适合 Hook 或权限层。
- 真正读取和评论 Jira:这是远程能力,适合 MCP 或专用 Tool。
- 最终回答必须包含风险和验证结果:可以由 Skill 规定,或用 Output Style 统一格式。
一个需求往往会组合多种扩展。关键是每层只承担自己的责任。
Skill 为什么要渐进式披露
把所有 Skill 正文都放进 system prompt,会迅速膨胀上下文。更合理的流程是:启动时只暴露名称和简短描述;任务命中后,Agent 再完整读取对应说明;说明引用的脚本和参考资料按需加载。
Skill 的价值在于把可靠工作流和领域知识打包,而不是偷偷获得额外权限。它调用的工具仍要经过同一套权限、Hook 和审计。
同名 Skill 还需要明确优先级,例如项目级覆盖用户级。否则不同机器加载顺序不同,同一个名称可能执行不同流程。
User Command 不是 Shell 别名
用户命令适合把重复输入变成模板:
/review src/auth.ts
展开为:
请从安全、正确性和可测试性三个角度评审 src/auth.ts,
只允许使用读取和搜索工具,不修改文件。
模板只产生 Prompt,不应直接执行 Shell。它可以为本轮选择模型或收窄工具,但不能扩大权限,也不能绕过 Hook。
命令参数要经过 tokenizer,而不是简单 split(' '),否则带空格的路径和引号很容易解析错。
Hook 适合横切策略
Hook 看到生命周期事件,例如会话开始、Prompt 提交、工具执行前后和子任务结束。它适合组织级规则:
- 命令包含生产环境时要求确认。
- 工具输出写入审计前先脱敏。
- 子 Agent 完成后把摘要发到内部通知系统。
Hook 不适合承载主要业务能力。用 pre-hook 偷偷替模型读取 Jira,调用轨迹会变得不可见,也难以让模型理解结果来自哪里。
Hook 协议要有明确的超时、退出码和失败语义。安全 Hook 失败时默认阻断,可选统计 Hook 失败时允许降级。
什么时候选 MCP
MCP 的优势是标准化发现、Schema 和调用协议,一个服务可以被多个 Agent 客户端复用。适合已经服务化、需要跨项目共享的外部能力。
但 MCP 不会自动解决:
- 服务端本身的权限设计。
- 工具过多导致的选择困难。
- 网络超时和版本兼容。
- 敏感结果是否应该进入上下文。
如果只是当前项目调用一个稳定的本地脚本,专用 Tool 往往更简单。为了“看起来标准”强行上 MCP,会增加进程、配置和排障成本。
一棵简单决策树
面对新需求,可以依次问:
只是改变回答形式? -> Output Style
只是复用一段请求? -> User Command
是知识和步骤? -> Skill
要观察或拦截生命周期? -> Hook
要执行本地动作? -> Local Tool
要连接共享远程服务? -> MCP
如果答案跨两层,就组合,不要把所有逻辑硬塞进一个机制。
扩展越多,启动越脆弱
外部服务和用户扩展都应默认可禁用。一个可选 MCP 连不上,不该阻止第一次启动;一个格式错误的项目 Skill 应给出来源和错误,但不应让帮助命令失效。
扩展加载还要记录来源和覆盖关系。出了问题,用户需要知道当前调用的是内置、用户级还是项目级版本。
用 Fallback 做个小练习
为“生成并发布周报”设计扩展方案,至少包含:
- 一份 Skill:规定数据收集和审批步骤。
- 一个 User Command:接收日期范围。
- 一个 Tool 或 MCP:读取项目数据。
- 一个 Hook:发布前检查是否包含敏感信息。
然后写清楚:哪一层失败时允许降级,哪一层失败时必须停止。
Config 到 Fallback 的小结
扩展体系的核心是选对层。知识交给 Skill,模板交给 Command,表现交给 Output Style,横切策略交给 Hook,执行能力交给 Tool 或 MCP。边界清楚后,权限、审计和测试才有落点。
最后一篇回到一个最现实的问题:Agent 没报错、也给了答案,怎么证明它真的完成了任务,而且没有以失控的成本或危险方式完成?
真正麻烦的是连接活着时和死掉时不一样
本地 Tool 通常跟 Agent 在同一个进程里,MCP 却多了一段连接生命周期。服务启动成功,只能说明握手完成,不代表五分钟后的调用仍可用。网络会闪断,服务端会升级 Schema,认证会过期,子进程也可能留下半开的管道。
可以把一次 MCP 会话拆成六个明确阶段:
stateDiagram-v2
[*] --> Configured
Configured --> Connecting: "读取配置并创建 transport"
Connecting --> Ready: "握手 + tools/list"
Connecting --> Degraded: "超时或认证失败"
Ready --> Calling: "tools/call"
Calling --> Ready: "返回结构化结果"
Calling --> Degraded: "连接中断"
Degraded --> Connecting: "满足重连策略"
Degraded --> Closed: "禁用或退出"
Ready --> Closed: "正常清理"
Closed --> [*]
这些状态不要藏在一个 client !== null 判断里。Ready 说明工具列表可用,Degraded 说明当前不可调用但宿主仍可继续,Closed 说明资源已经释放。这样 Dashboard、日志和用户提示才能说清楚“Jira 扩展暂时不可用”,而不是把整个 Agent 说成启动失败。
用一条断线轨迹看错误应该落在哪里
设想模型刚选择 jira_add_comment,但 MCP 服务在执行前断线。宿主不应把它记成“模型没调用工具”,因为调用意图已经产生;也不能把不确定结果当成失败后安全重试,因为第一次请求可能已经到达服务端并成功写入,只是响应丢了。
{
"tool": "jira_add_comment",
"callId": "mcp-91",
"phase": "awaiting-response",
"delivery": "unknown",
"retryable": false,
"userAction": "先读取工单评论,确认是否已写入"
}
读操作通常可以按退避策略重试,写操作则需要幂等键、服务端查询或人工确认。把所有网络错误都标成 retryable: true,会让 Agent 重复发评论、重复建工单,甚至重复付款。协议统一了调用格式,却没有替业务决定幂等性。
工具发现也有预算
一个企业 MCP server 可能暴露两百个工具。把全部 Schema 每轮都发给模型,既花 token,也让工具选择变难。宿主可以先展示稳定的工具摘要,再根据任务搜索或激活小集合;激活以后仍要经过本地权限和 Hook。
这里要区分“服务器说它有这个工具”和“当前用户可以调用这个工具”。MCP 的 tools/list 是能力发现,不是授权凭证。最终可见集合可以写成一个交集:
本轮工具 = 服务端已发现
∩ 本地启用配置
∩ 用户权限
∩ 当前任务 allowed-tools
∩ Hook 未阻断
如果服务端重新连接后 Schema 改了,Registry 需要原子替换这台 server 的工具集合,不能一边调用旧定义、一边逐个覆盖新定义。正在执行的旧调用按旧快照收尾,新一轮模型请求再看到新 Schema,边界会清楚很多。
从配置到 Registry 的阅读路线
| 现场步骤 | q-code 中的可选参考 | 重点核对 |
|---|---|---|
| 1. 发现配置 | src/mcp/config.ts |
用户级与项目级配置如何合并,禁用项和错误来源怎样保留 |
| 2. 建立连接 | src/mcp/client.ts |
transport、握手、超时、工具适配与关闭是否处于同一资源生命周期 |
| 3. 登记能力 | src/mcp/registry.ts |
server 名称、连接对象和工具快照如何一起增加、查询和删除 |
| 4. 进入执行管线 | src/tools/registry.ts |
MCP 工具是否仍经过统一审计、Hook、参数校验和结果包装 |
这四个文件不是 MCP 的唯一写法,它们提供的是一个检查角度:连接管理不能绕开工具治理,工具治理也不该假装网络永远可靠。
给扩展系统做一次拔网线演练
先连接一个只读的测试 MCP,让 Agent 成功列出并调用工具;随后停止服务端,再发一次相同请求。合格的结果不是“自动恢复一切”,而是下面几件事都说得通:宿主继续可用,失效工具不再被模型选择,审计中能看到 disconnect,用户得到简短的降级提示,恢复服务后 Registry 不会出现重复工具。
然后把场景换成写操作,在服务端处理成功但客户端收响应前断线。系统应该把交付状态标成 unknown,并要求查询验证,而不是盲目重试。这一次演练能测到的,远比启动时看到一行“Connected”更多。
MCP 配置也是一条信任链
一个 server 配置通常包含命令、参数、环境变量或远程 URL。项目仓库里的配置可以告诉客户端“连接什么”,不应自动继承用户所有环境变量,更不应把 token 写回仓库。启动子进程时只传必要环境,日志对命令参数和 endpoint 做脱敏。
用户级和项目级配置合并时,要显示最终来源。项目可以新增一个服务,但是否允许它启动本地命令、访问网络或读取凭据,仍由本机策略决定。打开陌生仓库就自动启动任意 MCP 子进程,等于把“查看代码”变成了代码执行。
远程 MCP 还要处理 TLS、认证和服务身份。连接到同名 jira 不代表连接到同一个组织实例;Registry 名称可以带配置来源或稳定 serverId,审计记录脱敏主机和 schema hash,避免把测试与生产服务混为一谈。
Schema 兼容不能只看工具名
服务端升级后,create_issue 仍叫同一个名字,却新增必填字段或改变枚举意义。客户端重连时应比较 schema hash,把变化应用到下一轮工具工作集;正在进行的旧调用保留当时版本。
兼容变化可以自动接受,例如增加可选描述字段;破坏性变化应记录 warning,并让旧会话恢复进入需要检查的状态。不能把旧参数交给新服务后,再把 400 当成模型犯错。
MCP 统一了发现与调用,却没有定义每个业务工具的长期版本策略。生产服务仍需要契约测试:固定几组 request/response,在服务端升级前验证客户端关心的字段和错误语义。
取消、关闭和重连要有所有者
主进程退出时,stdio server 需要关闭输入、等待有限时间,再终止子进程;HTTP/SSE 连接需要取消订阅和在途请求。若每个 tool call 临时创建一个 client,连接成本和泄漏都会失控;若全局单例永不关闭,测试和多项目切换又会互相污染。
把连接所有权放在 Runtime:配置加载后创建,Registry 只保存可调用引用,退出或项目切换时由 Runtime 关闭。tool call 使用局部 AbortSignal,但取消一次调用不应误关整台共享 server。连接断开事件则让该来源的工具工作集失效,并通知后续请求重新选择能力。
重连使用退避和上限,避免服务不可用时刷屏。用户主动禁用后不再自动重连;认证失败也不应无限尝试。可恢复网络错误、配置错误和明确禁用必须是不同状态。
什么时候不该用 MCP
一个只在本项目调用、输入输出稳定、没有跨客户端复用需求的本地脚本,包装成普通 Tool 往往更直接。MCP 增加 transport、配置、连接、版本和独立进程,收益必须来自标准化共享,而不是“架构听起来新”。
相反,一个由平台团队维护、同时服务多个 IDE 和 Agent、需要独立认证与发布节奏的 Jira 能力,MCP 的边界就很自然。判断依据是所有权和复用,不是工具数量。