上一篇讲了为什么把 Agent Skill 做成 51 行薄壳:业务流程必须留在服务端,客户端只保留「怎么找到流程」。
这个设计要成立,全靠两个 MCP 工具撑起中间的桥:
yi_list_capabilities——列出当前身份可用的能力目录yi_get_capability_protocol——按能力 ID 拉取完整执行协议
这一篇拆解这两个工具的接口设计。看起来只是「一个 list 一个 get」,但接口怎么定,直接决定了薄壳能不能保持薄、权限能不能收得住、协议能不能安全下发。

零参数的 list:身份不由客户端声明
先看 yi_list_capabilities 的定义:
server.registerTool(
"yi_list_capabilities",
{
description:
"列出当前身份可按需加载的领域能力,以及各自适用场景和协议工具。仅返回当前身份有权使用的能力。",
inputSchema: {},
},
handler
);
inputSchema: {}——零参数。这是有意的。
一个容易踩的直觉是把接口设计成 yi_list_capabilities({ userId }) 或者 { role: "admin" }。但只要「我是谁」由客户端传入,就等于把身份声明权交给了最不该拥有它的一方。正确做法是:身份来自 MCP 连接的凭据(API Key 的 scope、OAuth token 的 claim),服务端从认证上下文里取,客户端一个字都不用说。
零参数还有个附带的好处:接口永远不会因为「过滤条件」膨胀。需要什么、能用什么,服务端说了算。
目录条目:把控制流也下发了
看返回结构(节选):
{
"success": true,
"capabilities": [
{
"id": "work-reporting",
"name": "工作汇报",
"description": "记录工作素材,生成日报、周报、月报和工作总结",
"whenToUse": ["记录工作", "日报", "周报", "月报", "工作总结"],
"protocolTool": "yi_get_capability_protocol",
"protocolInput": { "capabilityId": "work-reporting" }
},
{
"id": "user:ck…",
"name": "…",
"whenToUse": ["…"],
"protocolTool": "yi_get_capability_protocol",
"protocolInput": { "capabilityId": "user:ck…" },
"isUserDefined": true
}
]
}
逐字段说设计意图:
whenToUse:给 Agent 做路由匹配的关键词表。薄壳里的规则是「从 whenToUse 选择最匹配的 capability」——也就是说,Skill 的触发词路由被搬到了服务端。加一个触发词(比如用户习惯说「记一下」)是改服务端数据的事,不用动客户端。protocolTool+protocolInput:这两个字段是点睛之笔。目录不只告诉你「有这个能力」,还告诉你「下一步调哪个工具、传什么参数」——把下一步的控制流原样下发。Agent 不需要硬编码「拿到 id 之后去调那个协议工具」,它只需要会读目录、照着做。将来协议入口换了工具名(或加了缓存层),薄壳一行不用改。isUserDefined:标记user:前缀的用户自定义能力,提示 Agent 这类能力的协议里有额外的toolBindings结构,下一篇展开。
目录是推导出来的,不是维护出来的
服务端的内置能力注册表长这样:
export const ASSISTANT_CAPABILITIES = [
{
id: "work-reporting",
displayName: "工作汇报",
description: "记录工作素材,生成日报、周报、月报和工作总结",
whenToUse: ["记录工作", "日报", "周报", "月报", "工作总结", "生产日报"],
requiredToolsets: ["work-logs"],
optionalToolsets: ["automation"],
protocolKey: "work-logs",
},
{
id: "personal-automation",
displayName: "个人自动化",
description: "创建、查询和管理个人提醒、定时任务与自动执行记录",
whenToUse: ["提醒", "定时", "自动执行", "自动生成", "执行历史"],
requiredToolsets: ["automation"],
protocolKey: "automation",
},
] as const satisfies readonly AssistantCapability[];
注意 requiredToolsets——它既是能力的技术依赖,也是可见性的判定条件:
一个能力对当前身份可见,当且仅当它的所有必需工具集都有 read 权限。
权限模型的细节(工具集 × 动作的二维 scope、read < write < admin 的等级比较)我在之前两篇里专门写过,这里只讲它和目录的组合效果:
目录不是一份需要维护的清单,而是权限状态在能力维度的投影。
这个「投影」语义带来两个很省心的性质:
- 权限收紧自动生效。把某用户的
work-logsread 权限收掉,下次拉目录「工作汇报」能力就消失了——不需要任何「下架」操作。 - 依赖下线自动传导。用户自定义能力(
user:前缀)还会校验它引用的平台能力是否全部可用;任何一个依赖被下线或失去授权,这个自定义能力自动从目录里消失。你不需要写「级联下架」的代码,因为目录从来不是存量数据。
用户自定义能力还有一个收窄:下发时只带 workflow 实际引用的平台能力集合。编辑态声明的白名单可以比运行态宽(方便用户增删步骤),但多声明的部分不会下发为运行时依赖——「声明的」和「能用的」是两个集合,后者永远是前者的子集。
get:每次调用都重新校验
yi_get_capability_protocol 的入参就一个 capabilityId,返回结构:
{
"success": true,
"capabilityId": "work-reporting",
"protocolVersion": "4.0.1",
"updatedAt": "2026-07-21",
"protocol": "(Markdown 全文)"
}
两个值得说的细节。
第一,鉴权在每次调用时重新做。 不是「会话建立时校验一次、之后默认放行」。权限是会随时间变化的——用户中途被降权、能力被停用,正在进行的会话也必须立刻受到约束。「拉过目录」只代表当时有权,协议下发那一刻服务端再查一次。
第二,错误不区分「不存在」和「无权」。 两种情况返回同一个错误:
{ "success": false, "error": "能力不存在或当前身份无权使用" }
因为一旦区分,攻击者就可以拿错误信息当预言机,枚举探测系统里存在哪些能力 ID。对内部平台这是个过度设计吗?我不这么认为——探测不需要恶意,一个好奇心旺盛的用户让 Agent「把所有 capabilityId 都试一遍」就够了。统一错误是便宜且正确的默认。
一个反直觉的取舍:目录工具不做 scope 限制
按前面「权限投影」的逻辑,一个自然的想法是:这两个目录工具本身也该挂在某个 toolset scope 下面,没授权就连目录都看不到。
我们没有这么做。yi_list_capabilities、yi_get_capability_protocol 和 get_current_user 一起,对所有已认证会话无条件注册,不受任何业务 toolset scope 约束。代码里的注释写得很直白:避免 scope 配置错误时,Agent 连「我是谁、我有什么」都无法确认。
这是一个可用性与最小权限的取舍:
- 收紧的做法:scope 配错时用户看到的是一堆工具凭空消失,Agent 无从解释原因,排障要走服务端日志;
- 我们的做法:scope 配错时 Agent 至少能拉到目录(里面只剩无权限要求的能力)并告诉用户「当前身份缺少 XX 权限」,用户知道该找谁。
「身份与能力清单」属于元信息,泄露面小、排障价值高——这类工具放在 scope 体系外面,性价比是划算的。但注意边界:目录不受限,协议内容依然受限——拉取协议时逐能力校验,一层都没少。
协议本体:为什么是 Markdown
协议最终下发的是一份 Markdown 全文。选 Markdown 而不是结构化 JSON,理由只有一个但足够硬:协议的消费者是 LLM,Markdown 就是它的原生指令格式。给人写文档用什么格式,给 LLM 写协议就用什么格式。
看一份真实协议的骨架(工作汇报能力,脱敏节选):
---
protocol_version: 4.0.1
protocol_updated: 2026-07-21
---
# 工作汇报能力协议
本协议由 work-reporting 能力动态下发,覆盖工作素材、报告生成与
工作日志设置。所有定时行为统一由 personal-automation 能力管理;
不要寻找或调用旧的 work_log_schedule_* 工具。
## 工具与用途
| 工具 | 用途 |
| ---------------------- | ---------------------- |
| work_logs_record | 记录一条工作或计划素材 |
| work_logs_list_entries | 查询指定日期素材 |
| work_logs_generate | 异步生成指定日期日报 |
| work_logs_query | 查询日报状态与内容 |
| …(共 15 个工具) |
## 语义检索报告
- 询问历史工作时先调用 work_logs_search……
- 基于命中的片段回答,并标注日期;检索片段不是当前事实,
不能补写未命中的内容……
## 工作素材规则
- 只记录用户明确提供的事实,不补写或猜测
- 已完成事项 kind="work",后续计划 kind="plan",混合内容拆两次调用
- 修改、删除前先列出素材并让用户确认目标
## 日报与周期报告
- 生成是异步的,随后通过查询工具轮询
- PENDING / POLISHING 表示正在处理,不要重复触发;
FAILED 时展示原因并询问是否重试;STALE 表示源内容在生成后
发生变化,须由用户确认后重新生成……
## 设置 / 首次配置
(两段式问询话术、默认 Cron 时间、幂等发布规则……)
写这类协议,我们沉淀了几条经验:
1. 状态机的语义要写给 LLM 看。 PENDING、POLISHING、FAILED、STALE 每个状态意味着「该做什么、不该做什么」必须写进协议。工具的返回值能告诉你状态是什么,但只有协议能告诉 Agent 拿到这个状态后的正确行为——比如「POLISHING 时不要重复触发」。这类知识放在工具 description 里太挤,放在协议里正合适。
2. 明确「不做什么」和「做什么」同样重要。 「不要将报告内容写入用户记忆」「不补写或猜测」「不要寻找旧工具」——负面指令是协议的一半。尤其「不要寻找或调用旧的 work_log_schedule_* 工具」这句:工具下线了,但可能存在于 Agent 的记忆或宿主的旧文档里,协议要主动覆盖这些残留。
3. 不把对话状态写进协议,也不让 Agent 存。 协议里有句原则性的话:「只保存投递方式,绝不记录『已询问』『已问过』等对话状态」。状态判断每次从工具查询获得(idempotent read),协议本身保持无状态——这和上一篇「协议不进记忆」是同一个思想的两面。
4. 跨能力引用也走目录。 注意协议里这句:「定时生成……按能力目录加载 personal-automation 协议再操作」。能力 A 的协议需要用到能力 B 时,不把 B 的内容抄进 A,只指个路。这样 B 更新时 A 不需要同步改,能力间保持单向依赖。协议是 Markdown,但能力拓扑不能是随意的网状。
两个实现坑
最后记两个实现层面的坑,都是 Next.js 生态的朋友会撞上的。
坑一:Next.js 生产 bundle 里读不了文件。 协议是 .md 文件,服务端启动时从磁盘读取。开发模式一切正常,生产构建后 import.meta.url 在被 bundle 进 .next 目录的代码里不可靠——按它推导项目根目录会指错地方。解法是从进程工作目录向上逐级探测标志文件(package.json、protocols/ 目录)确定真实的项目根。凡是「Next.js + 读磁盘文件」的组合都会遇到,值得单独记一笔。
坑二:协议文件不存在时,宁可不返回。 getCapabilityProtocol(key) 找不到文件时返回 null,上层直接给 Agent 返回失败,绝不回退到 stub、不让 Agent「凭常识继续」。这与上一篇的失败规则呼应:一份缺失的协议,宁可让 Agent 说「暂时不可达」,也不能让它脑补一份。动态下发体系的信任建立在「协议要么是新的、要么没有」之上,没有第三种状态。
小结
这一篇的要点压缩成四句:
- list 零参数——身份由凭据决定,不由客户端声明;
- 目录条目带
protocolTool+protocolInput——控制流和内容一起下发,薄壳才能保持薄; - 目录是权限的投影,不是维护出来的清单——权限收紧、依赖下线都自动传导;
- 协议用 Markdown 写给 LLM,但「不存在」必须就是「不存在」,没有回退。
还有一个贯穿两篇却没正面回答的问题:协议在变、目录在变、工具在变,版本怎么管? 客户端怎么知道该刷新了?下一篇讲这套系统里的四套版本号,以及「把版本管理从客户端挪回服务端」到底意味着什么。
本系列下一篇:《四套版本号:把 Agent Skill 的版本管理挪回服务端之后》