一个只收 instruction 字符串的工具,为什么一定出事
MCP 服务端设计的约束最后只剩一条:工具接收带类型的参数、返回带类型的结果。做不到,它就没有可断言的边界,因为它的输入是模型分布里的一次采样,而断言没法写在一个分布上。
接受自然语言指令的 MCP 工具无法被测试;只有接收带类型参数、返回带类型结果的工具,才配得上一个回归集。
我最早交付的那台 server 就栽在最普通的地方。它暴露了一个 run_report,schema 只有一个字段:{ instruction: string }。handler 拿到这串话开始猜:正则抠日期范围、查表对上三十几个指标名、匹配不上就回落到"按月收入"。演示跑得很好,这才是陷阱——演示是一个采样,任何东西在单个采样上都能跑通。
上线两个月,它产出了三个触达人的缺陷:季度边界少算一周;欧元账户被按美元汇总,因为默认币种写了 USD;一张表在 500 行处被 LIMIT 静默截断。三个都复现不了,因为改一次 agent 的 prompt,进到工具里的东西就换了一批。git 里 handler 一个字没动,行为上它已经是另一个程序。
有类型的工具契约里到底放什么
三个对象:input schema、output schema、handler。模型决定调用之前,会先在 tools/list 里读前两个。
export const LineItem = z.object({
sku: z.string().regex(/^[A-Z]{3}-\d{4}$/),
quantity: z.number().int().positive().max(10_000),
unitPriceMinor: z.number().int().nonnegative(),
});
export const QuoteRequest = z.object({
accountId: z.string().uuid(),
currency: z.enum(["USD", "EUR", "CNY"]),
lines: z.array(LineItem).min(1).max(200),
});
export const QuoteResult = z.object({
quoteId: z.string().uuid(),
totalMinor: z.number().int().nonnegative(),
flags: z.array(z.enum(["credit_hold", "below_floor", "manual_review"])),
});
这几行各自对得上一个我见过的缺陷。金额用最小单位的整数,因为 JSON 没有小数类型:钱一旦是浮点,服务端算出的和你账上记的会差一分,还只在部分账户上差。币种是 enum 而不是 string,因为 usd 和 USD 对人是同一件事,对代码是两条分支。明细数组封顶 200 条,否则某天会有四千条进来,在请求线程上解析九秒。
sku 的正则比我写的任何说明都值钱。模型发来 ab-1,会收到点名到路径的校验错误,下一次基本能改对;读到"三位大写字母加连字符加四位数字"这段散文时,它大多数时候能对——这是最坏的结局:对得足够多,能过演示;错得足够频繁,能触达人。
handler 是从哪里开始的
handler 是普通函数:拿到校验过的对象,返回约定的结果形状。它不 import MCP,也不知道自己正被 agent 调用。
export function createQuoteHandler(repo: QuoteRepo) {
return async ({ accountId, currency, lines }: QuoteRequestInput): Promise<ToolResult<QuoteResultOutput>> => {
const account = await repo.findAccount(accountId);
if (account === null) return { isError: true, content: [{ type: "text", text: "unknown_account" }] };
const draft = await repo.createQuote({ accountId, currency, lines });
const structuredContent = QuoteResult.parse(draft);
return { content: [{ type: "text", text: `quote ${draft.quoteId} created` }], structuredContent };
};
}
export function registerQuoteTool(server: McpServer, repo: QuoteRepo): void {
server.registerTool("create_quote", {
description: "Create a quote from validated line items. Amounts are integer minor units.",
inputSchema: QuoteRequest.shape,
outputSchema: QuoteResult.shape,
}, createQuoteHandler(repo));
}
只有 registerQuoteTool 这一处 import 了 SDK,这个隔离就是全部意义:测试不需要 transport、不需要 server 进程、不需要模型。
这里有两类失败,混在一起本身就是缺陷。schema 违规是调用方的问题,正确回应是告诉它出错的路径,让下一次尝试能对。领域拒绝是业务的问题——账户不存在、超出授信、低于底价——重试只会花掉 token 换来同一个答案。我的 server 前者当校验错误返回,后者返回 isError: true 加一个短错误码,agent 循环的处理不同:一个重试一次,另一个停下来上报。
调用方是模型,回归集还能怎么写
三层,每层接住别的层看不见的缺陷。第一层是 schema 测试:喂进各种形状的输入,断言校验在 handler 跑起来之前就拦住,不需要模型也不需要网络。第二层是 handler 测试,用固定 fixture 钉住行为;测试里的 repo 是内存实现并数写入次数,这才让第一条断言有意义——它能证明校验发生在任何副作用之前,而一个插库之后才跑的 schema 只是装饰。
第三层最常被跳过:从真实流量里按 tool call 记录 fixture——决策前的对话、模型选中的工具、它填的参数——每周给工具选择打分:选对工具且每个必填参数完全匹配的比例。四十条这样的三元组就够抓出一次改坏账户解析的 prompt 编辑。打分只看工具名和参数,它们能逐字比较。
| 失败 | 在哪一层被接住 | 能否重试 | 调用方收到什么 |
|---|---|---|---|
| 该给数量的地方收到字符串 | handler 执行前的 schema 校验 | 可以,一次,带出错路径 | lines.0.quantity: expected number |
| 账户在系统里根本不存在 | handler 查库,领域拒绝 | 不可以 | unknown_account |
币种小写成 usd | enum 校验 | 可以 | currency: invalid enum value |
| 一次传进四千条明细 | schema 里的数组上限 | 可以,并告知上限 | lines: too big, max 200 |
| 上游定价接口 503 | handler 捕获的传输错误 | 可以,有限次数 | upstream_unavailable |
| 工具选对了账户选错了 | 只看工具选择的 fixture 打分 | 不可以,这是 prompt 缺陷 | 什么都没有,这是你自己的 bug |
| 类型全对金额算错 | 拿真实发票做的领域测试 | 不可以 | 什么都没有,schema 看不见真值 |
最后两行是有类型契约必要但不充分的原因:类型挡得住"边界是语言"带来的失败,挡不住选错工具,也不会让算术正确。字段级约束怎么定、handler 怎么抽干净,都在能扛住生产流量的 MCP 服务端设计里。
有类型的边界值多少 token、多少工时
schema 不是免费的:工具定义每个请求都要发一遍,一段话的描述就是每个回合都在付钱。按示例单价算一道,换成你供应商的现价,结构不变:14 个工具、描述平均 90 token,等于每请求 1,260 token 的工具定义;每月 40,000 次请求就是 5,040 万 token,按每百万 $2.50 算是约 $126。同样的 14 个工具若把描述写成段落并附上重试建议,平均 190 token,就是 1.064 亿 token、约 $266。
| 工具定义风格 | 每次请求增加的 token | 月成本,未缓存 | 同口径,开 prompt 缓存 |
|---|---|---|---|
| 14 个工具,一句话描述,约束写在 schema 里 | 1,260 | 约 $126 | 约 $12.6 |
| 14 个工具,段落描述加重试建议 | 2,660 | 约 $266 | 约 $26.6 |
结论不是把工具砍少,而是把约束放进 schema:短,而且由机器执行。另一半是工时,这是我自己计的数,不是研究结论:逻辑本来就在一个函数里时,补 schema、抽 handler、写五条测试大约 2 到 4 小时;定价逻辑和传输处理缠在一起时是 2 到 4 天,真正的工作是那次剥离。
什么情况下不该给工具套 schema
输出本来就是开放的时候(总结客服会话、起草回复),别为正文硬造一个很大的对象:把边界类型化,让散文留在里面——入参 { threadId, maxWords, format },出参 { summary, sourceCount }。九字段的输出对象里四个是空字符串,这种情况我见过不少,那是 schema 在花钱编码"没有内容"。
代码解释器或 SQL runner 是另一种:灵活性就是产品,查询语句没法类型化,契约只能移到沙箱边界上——能读哪几张表、行数上限、语句超时、结果形状。给一个人用一周的原型也不必写:第二个调用方出现时账就要重算,边界从此有了读不到你代码的消费者。
最后一种错误更隐蔽:四十个值的 enum 跟着数据库表同步,会让加一条产品线变成一次发版,换成 pattern 加服务端查表即可。
下一个工具上线之前先做什么
挑出你那台 server 上唯一还收自由文本参数的工具,写出它的 schema,把 handler 抽成普通函数,补五条测试:三条给 schema,两条给领域逻辑。上线前后各量一周重试率——在我迁移过的 server 上它是降的,因为点名路径的校验错误,比同一段含糊的话加更多字更可能改对。先迁移碰钱的那几个工具,它们的错误答案带着一个数字和一个证人。回归集不是设计做完之后补的质量文档,它是别人不必先问你就能改这套系统的原因。
继续阅读
- 人在回路设计:让人审决策和证据,而不是一屏模型输出2026-02-226 分钟AI 系统架构
- 如何用 Next.js 与 MCP 构建零边际成本的企业级 AI 工作流2026-02-1811 分钟AI 系统架构
- AI 系统可观测性不是一块监控面板:复现不了故障,就等于还在跑演示2026-02-106 分钟AI 系统架构