这是《Agent全栈开发实战》的第 5 篇。系列以 catbuddy,由浅入深拆 harness 设计。上一篇(第 04 篇)把「手脚」的内核讲透了:工具怎么注册调度、文件读写怎么不越权。但内置的那十几个工具只是个起点------真实需求里你总会想接 GitHub、接飞书、接你公司内部的某个服务。这篇就聊「手脚」的另一半:怎么用一套标准协议( MCP )把外部工具生态接进来,再用 Skill 告诉 Agent「这些能力什么时候用、怎么组合」。独立读也没问题,知道「Agent 靠工具干活」就够入场了。
工具够了,但 Agent 还是「不会做事」
上一篇结束时,catbuddy 的 Agent 已经长出手脚了:能读文件、能改代码、能跑命令、能搜网页。按理说该万事大吉。
但你很快会撞到两堵墙。
第一堵墙:内置工具永远不够。 用户今天要操作 GitHub 仓库,明天要往飞书文档里写东西,后天要查公司内网的一个数据库。你总不能每接一个外部服务,就回 harness 里手写一坨胶水代码、改一次核心、发一个版吧?这堵墙,靠 MCP 拆------给 Agent 一个标准插口,外部能力插上就能用。
第二堵墙:会用工具 ≠ 会做事。 你把 write_file、exec、web_search 都给了 Agent,让它「做一个 PPT」。它知道每个工具怎么调,但「先理解需求、再搜素材、然后逐页生成、最后预览」这一整套剧本 ,它得自己摸索------摸索就意味着不稳定。这堵墙,靠 Skill 拆------给 Agent 一本工作手册,写清楚什么时候、按什么步骤、怎么组合这些工具。
一句话点题,也是这篇的主线:
MCP 是给手脚(把能力接进来),Skill 是教套路(把能力编排好)。

下面分两半讲。
MCP:别为每个外部能力写专用胶水
1.1 一个 USB 类比就够了
MCP 全称 Model Context Protocol(模型上下文协议) ,是 Anthropic 提出的一个开放协议。名字唬人,核心思想其实一句话能说完:
不要为每个外部能力写专用胶水,用一套标准协议统一接入。
想想 USB 之前的世界:鼠标插 PS/2 口、打印机插并口、U 盘插串口,每接一个新设备都得换接口、装专用驱动。USB 出现之后,不管你插什么,物理接口和通信协议都一样------插上就认。
MCP 干的是同一件事。不管你给 Agent 接的是文件系统、Shell、浏览器还是数据库,通信格式都是同一套标准。Agent 这边不用为每种外设写专用驱动,外部服务那边也不用为每个 Agent 客户端做适配。
协议本身只定义了三个核心动作,简单到没什么可讲:
list_tools():Agent 问服务端「你有什么工具?」服务端返回工具名 + 参数定义(JSON Schema)。call_tool():Agent 说「帮我执行这个工具,参数是这些」,服务端执行并返回结果。tool_result:服务端把结果回传,Agent 喂回 LLM 继续推理。
复杂度全压在参数的 JSON Schema 定义上,交互模型就这三下。
我们当初也犹豫过:要不自己定义一套工具接口?interface Tool { name, schema, execute },半天就能写完。但「自己定义」意味着工具开发者得学 catbuddy 的私有规范、已有的上百个开源 MCP 服务全用不上、和别的 AI 工具之间也没法互通。选 MCP,本质是放弃了「重新发明轮子」的诱惑------接入整个生态的价值,远超那半天工时。
1.2 McpManager:把外部工具「翻译」成内部工具
协议是死的,得有人在 harness 里把它跑起来。这个人就是 McpManager。它干三件事:管连接的生命周期、做格式 归一化 、把外部工具注入 registry。
先看它怎么把一个外部 MCP 服务的工具,变成 Agent 眼里和内置工具一模一样的东西:

落到代码上,关键就两步,都在 createMcpToolWrapper() 里:
javascript
// tools/mcp.ts ------ 把一个外部 MCP 工具包装成内部 Tool
const name = sanitizeMcpName(`mcp_${serverName}_${toolDef.name}`) // ① 加 mcp_ 前缀 + 洗名
const parameters = normalizeSchemaForOpenai(toolDef.inputSchema) // ② JSON Schema 归一化
return {
name,
definition: { type: 'function', function: { name, description, parameters } },
execute: (call) => executeMcpTool(client, originalName, name, call.arguments, timeout),
}
两个细节值得停一下:
① mcp_{server}_{tool} 前缀 + sanitizeMcpName 。 前缀保证了命名空间不撞车------你接两个都叫 search 的服务,也会变成 mcp_brave_search 和 mcp_github_search,互不打架。sanitizeMcpName 则把名字里不合法的字符(模型 API 对工具名有字符限制)统一替换成 _,省得外部服务起个怪名字把整轮调用搞崩。
② normalizeSchemaForOpenai 做格式 归一化 。 这是 MCP 接入里最容易被忽略、又最实在的脏活。MCP 的 schema 和各家模型 API 期望的 schema 不完全一样------比如 MCP 里常见的「可空类型」会写成 type: ["string", "null"] 或者塞在 anyOf 里,而某些 provider 不认这种写法。这个函数就把它们拍平成统一形态(把 null 抽出来变成 nullable: true、递归处理嵌套属性和数组项)。外部工具的 schema 从 MCP 格式,被归一化到了内部格式------做完这步,外部工具在 LLM 眼里才真的和内置工具长得一样。
包装完,一句 registry.register() 注入 ToolRegistry(注册机制第 04 篇讲过,这里直接复用),外部工具和内置工具就彻底平权 了:Agent 调度时根本不关心一个工具是 catbuddy 内置的,还是某个外部 MCP 进程提供的------它只看 name 和 description。这正是 USB 类比的精髓:统一接口之后,上层完全感知不到下层的异构。
1.3 连接生命周期:connect / reload / disconnect
外部服务不像内置工具那样「永远在」------它是个独立子进程,会启动失败、会断、会需要热重连。McpManager 把这套生命周期管了起来,对外就三个动作:

registerTools(registry):启动时调用。先注册一个特殊工具mcp_reload(让 Agent 自己能触发重连),再遍历配置里的每个 MCP 服务,挨个起子进程、client.connect()、listTools()、包装注册。某个服务连不上不会拖垮全局------失败信息收进lastFailures,其他服务照常工作。reload():配置变了、或者某个服务挂了,不用重启整个 App。它先unregisterByPrefix('mcp_')把所有旧的 MCP 工具从 registry 摘干净,再重新连一遍。返回的消息会告诉你3/4 server(s) connected, 27 tool(s) registered,连不上的还会附上人话版的失败原因(比如「npx 不在 PATH 里」「包名 404」)。disconnect():挨个client.close(),干净退出。
这套设计的好处和第 01 篇那条「器官只认接口」的主线是一脉相承的:MCP 的所有复杂度( 子进程 、stdio、断连重试、schema 差异)都被锁在 McpManager 这一个文件里。 registry 不知道、Agent Loop 不知道、LLM 更不知道。加一个外部服务,核心代码一行都不用动------改的只是配置。
1.4 安全:外部工具,走同一套边界
给 AI 开手脚已经够吓人了,现在还要把外部来路不明的工具也接进来------安全怎么办?
答案很省心:外部工具不享受任何特权,它和内置工具走的是同一套安全边界。 第 04 篇讲过的那几道门,对 MCP 工具同样生效,这里只点一下它们在哪、拦什么:

- 文件操作走 PathGuard。 任何工具想读写文件,路径都得先过
ctx.resolvePath()------越出工作区直接拒。细节第 04 篇讲过,不重复了。 web-fetch拦私有 IP 段防 SSRF 。web-fetch.ts抓 URL 前先validateUrlTarget(),把私有地址(10.x / 172.16.x / 192.168.x)、回环(127.x)、云元数据地址(169.254.169.254,AWS/云服务器的 credential 端点)全部拦掉。SSRF(服务端请求伪造)就是诱导服务端去访问它本不该访问的内网地址------这道门就是堵这个的。还有个细节:它在每一次重定向跳转 都重新校验一遍(fetchWithSafeRedirects里循环调validateUrlTarget),防止「先返回一个合法地址、再 302 跳到内网」这种 DNS rebinding 式绕过。exec拦危险命令。exec.ts在真正执行前用一条正则扫命令字符串:rm -rf/format/dd/mkfs/:()(fork bomb)/chmod 777命中即返回Error: dangerous command blocked,外加containsInternalUrl拦内网 URL。这不是完美沙箱------有经验的人总能绕字符串过滤------但它是防御性第一道门 :防止 LLM 在不知情的情况下顺手干出破坏性操作(你问「怎么清 node_modules」,它可能就甩出个rm -rf /)。
记住这个原则就行:MCP 扩大了能力边界,但没有扩大权限边界。 接进来的外部工具,照样被关在 PathGuard 和这几道门里面。
Skill:工具是器械,Skill 是动作说明书
手脚有了,外部生态也接进来了。但开头说的第二堵墙还在:Agent 会用每个工具,不代表它会把工具组合起来完成一件复杂的事。
2.1 健身房比喻
你第一次去健身房,教练给你一堆器械:杠铃、哑铃、拉力器、跑步机。你每个都会用。但「怎么练肱二头肌」------先做什么、后做什么、每组几次、组间歇多久?这得有张动作说明书。
- 工具( MCP )= 健身房器械。 回答「能干什么」:能读文件、能跑命令、能搜网页。
- Skill = 动作说明书 / 训练计划表。 回答「什么时候、怎么组合这些器械」:要做 PPT?先理解需求 → 再搜素材 → 然后逐页生成 → 最后预览调整。
在 catbuddy 里,每个 Skill 就是一个 Markdown 文件 (SKILL.md),内容三件套:
- 何时用------这个技能解决什么场景;
- 工具组合步骤------按顺序该调哪些工具;
- 判断规则------遇到岔路口怎么决策。
举个「PPT 生成」的例子,SKILL.md 大概长这样:
javascript
# PPT 生成技能
## 步骤
1. 理解用户需求(主题、受众、页数)
2. 用 web_search 搜集相关素材
3. 组织大纲,逐页生成内容
4. 用 python-pptx 生成 .pptx 文件
## 判断规则
- 用户说「做个 PPT」但没指定页数 → 先追问
- 内容是技术主题 → 默认加一页架构图
Agent 拿到这份说明书,就不用自己摸索「PPT 该怎么做」了------每一步该干嘛、卡住了怎么判断,写得明明白白。工具和 Skill 的分工,一张表说清:
| MCP 工具 | Skill | |
|---|---|---|
| 粒度 | 原子操作 | 完整工作流 |
| 内容 | 函数签名 + 参数 Schema | 何时用 + 步骤 + 判断规则 |
| 谁定义 | 工具提供方(catbuddy / 第三方) | Skill 作者(社区 / 你自己) |
| 比喻 | 螺丝刀、扳手 | 宜家安装说明书 |
2.2 四级渐进加载:10 个 Skill 只占 300 token 待机
这是 Skill 系统里我最想讲的一个设计点。
你可能会想:Skill 这么有用,那把所有 Skill 的正文全塞进系统提示不就完了?Agent 一来就什么都会,连「读说明书」这一步都省了。
别。算笔账你就明白了。 假设你装了 10 个 Skill,每个 SKILL.md 平均 2000 token,全灌进去就是 20000 token------占掉标准上下文窗口的 20%。你还啥都没干,五分之一的「注意力」就被一堆此刻根本用不上的说明书吃掉了。Skill 越多越要命:50 个就是 10 万 token,直接爆窗。
catbuddy 的做法叫四级渐进式加载 (Progressive Disclosure,渐进式披露)------核心就一句:摘要常驻,正文按需。 这套逻辑由 ContextBuilder 体系里的 SkillLoader(context/skill-loader.ts)负责:

逐级看:
① 摘要层(常驻)。 SkillLoader.buildSkillsSummary() 把每个可用 Skill 浓缩成一行 - name: description,拼成一个列表注进系统提示。10 个 Skill 也就 ~300 token。Agent 由此知道有哪些技能可用,但不知道每个具体怎么用------够它判断了。
② Always-on 正文(常驻)。 有两个技能特殊:memory(跨会话记忆)和 my(自查状态/配置),它们的代号在代码里写死成 ALWAYS_LOAD_SKILLS = ['memory', 'my']。这俩太基础、几乎每轮都要用,所以 loadAlwaysSkills() 把它们的完整正文也常驻。要是连这俩都得「先读说明书再用」,用户第一条消息就得多等一轮。
③ 普通 Skill 正文(按需)。 这是最巧的一层。普通 Skill 的正文不在系统提示里 ------Agent 只知道它「存在」。当 LLM 判断「这事得用某个技能」时,它自己用 read_file 去读那份 SKILL.md(对应 SkillLoader.readSkill())。比如你问「明天北京什么天气」,Agent 的推理链是:看到摘要里有 weather → 「该用 weather」→ read_file 读它的 SKILL.md → 按说明书去取数据 → 格式化输出。用一个,读一个,绝不预付。
④ 捆绑资源(按需)。 一个 Skill 文件夹不止 SKILL.md,还能带 scripts/(可执行脚本,Agent 用 exec 直接跑)、references/(参考文档,要时再读)、assets/(输出模板)。这让 Skill 不只是「一段提示词」,而是一个真正的可执行包。
省下来的账非常实在:待机成本从全量的 20000 token 压到约 300 token,单次也只多读一个 2000 token 的正文,整体省了约 90%。 Agent 不会因此「不知道用什么」------摘要足够它判断;它只是在真正动手前,多瞄一眼说明书而已。
这里只讲 Skill 这条「四级渐进按需加载」的支线。ContextBuilder 完整的五层装配流水线(人格、引导文件、分层记忆、技能摘要、工具清单怎么逐层拼成 LLM 的「视野」)是下一篇第 06 篇的主场,这里先按下不表。
2.3 工作区级 Skill:每个项目定制 Agent,还不碰 harness 代码
四级加载解决了「省 token」,还有一个更妙的能力:让每个项目都能定制 Agent 的工作方式,而完全不用改 harness 代码。
机制是这样的:Skill 有两个来源------内置的(跟 catbuddy 一起发的),和工作区级 的(放在项目里的 .catbuddy/skills/ 目录下)。扫描时,工作区目录排在内置目录前面 ,而且用一个 seen 集合去重:

落到 skill.ts 的 listDiscoverableSkills() 里,逻辑就是:先 scanSkillsRoot(workspace/skills, ...),再 scanSkillsRoot(builtinDir, ...),凭 seen.has(dir) 让同名的工作区 Skill 自动覆盖内置 Skill 。readSkill() 读正文时也是工作区路径排第一。
这意味着什么?你的 A 项目和 B 项目,可以各自在 .catbuddy/skills/ 里放一份同名但内容不同的 code-review 技能------A 项目按它的规范走、B 项目按它的来,互不干扰。给某个项目定制一套专属的 Agent 工作方式,你要做的只是往这个项目目录里丢几个 Markdown 文件------不发版、不改核心、不动 harness 一行代码。这又是第 01 篇那条「改动锁在边界内」主线的一次具体兑现。
2.4 热更新:装完下一条消息就生效
顺带说个用起来很爽的点。Skill 的安装/启用/禁用,不用 重启 、不用新开会话、没有「刷新技能列表」按钮------下一条消息就能用。
秘诀在 SkillLoader.fingerprint():它把 Skill 目录的 mtime(文件修改时间)算成一个指纹,作为系统提示缓存的 key 的一部分。你装了个新 Skill,目录 mtime 变了 → 指纹变了 → 系统提示缓存自动失效 → 下一条消息重新拼上下文时,新 Skill 的摘要就进去了。禁用同理:disabledSkills 变了,指纹变,缓存失效。(这套基于 mtime 的指纹缓存怎么让热路径零 I/O,也留给第 06 篇细讲。)
收个尾:手脚 + 套路 = 能干活的 Agent
把这两篇(04 + 05)连起来看,「手脚」这个器官就完整了:

| MCP | Skill | |
|---|---|---|
| 一句话 | 给手脚------把外部能力接进来 | 教套路------把能力编排成剧本 |
| 解决的问题 | 内置工具不够、不想写胶水 | 会用工具 ≠ 会做事 |
| 核心机制 | 标准协议 + mcp_ 前缀归一化注入 | 四级渐进加载,摘要常驻正文按需 |
| 落点文件 | tools/mcp.ts 的 McpManager | context/skill-loader.ts 的 SkillLoader |
| 关键词 | USB:插上就认,上层不感知异构 | 健身房:器械之上还得有动作说明书 |
模型负责思考,工具负责落地,而 Skill 负责告诉模型「这一步思考完,下一步该调哪个工具」。三者凑齐,Agent 才从「会聊天、会单点操作」长成「能端到端完成一件复杂任务」。
这篇讲了什么?
- MCP 给手脚 :一套标准协议(Model Context Protocol)统一接入外部工具,像 USB 一样插上就认。
McpManager管 connect/reload/disconnect,把外部 schema 归一化、加mcp_前缀注入ToolRegistry,让内置工具和外部工具在 LLM 眼里完全一样------而且照样走 PathGuard / SSRF / 危险命令拦截,能力变大但权限没变大。 - Skill 教套路:工具回答「能干什么」,Skill 回答「什么时候、怎么组合」。每个 Skill 是一个写着「何时用 + 步骤 + 判断规则」的 Markdown。四级渐进加载让摘要常驻、正文按需------10 个 Skill 只占约 300 token 待机,而不是 20000。
- 工作区级 Skill (
.catbuddy/skills/)能自动覆盖同名内置 Skill,给每个项目定制专属的 Agent 工作方式,全程不碰 harness 代码。
下一篇预告:手脚齐活了,但 Agent 每一轮到底「看到」了什么?这份视野------人格、记忆、技能摘要、工具清单------是怎么逐层拼出来的,又怎么在对话变长时不把上下文撑爆?第 06 篇聊「眼睛」:ContextBuilder 的五层装配 + 三层防线 + Token 精确计数与自愈。