1. 工具即契约:我们究竟在描述什么
当你在 MCP 服务器中注册一个工具时,你会用一个小对象来描述它。针对 TypeScript SDK 的简化结构如下:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "根据收礼人画像推荐礼物。",
inputSchema: {
type: "object",
// 我们现在就从这里深入
},
},
async ({ input }) => {
// 你的代码
}
);
模型并不知道处理器 async({ input })=> { ... } 里面是什么。对它而言只有三件事:
- name/title —— 工具叫什么。
- description —— 何时适合使用它。
- inputSchema —— 需要传哪些参数以及格式。
本讲我们所做的一切都与第 3 点相关(还有少量关于 _meta/annotations 的元数据,稍后会谈)。
务必理解:在 ChatGPT App 语境中,JSON Schema 不是乏味的校验器,而是模型提示词的一部分。 模型确实会读取字段的 description,理解 enum,注意到 minItems、format 等等。
也就是说,你不仅是在保护后端免受错误数据的影响,你也在告诉 AI 模型如何正确地调用你的函数。
2. 工具 suggest_gifts 的基础 JSON Schema
从简单的开始。假设我们有这样一个场景:
用户写道:
“给我25岁的哥哥挑选礼物,预算 50–70 美元,喜欢电子游戏和桌游。”
工具 suggest_gifts 应该接收大致如下参数:
- 收礼人的年龄;
- 关系类型(兄弟、同事、伴侣等);
- 最低与最高预算;
- 兴趣列表。
我们“直接”用 JSON Schema 来描述,不用 Zod,纯对象:
const suggestGiftsInputSchema = {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "收礼人的年龄(岁)。",
},
relationship: {
type: "string",
enum: ["friend", "partner", "sibling", "colleague", "parent"],
description:
"与收礼人的关系类型:friend、partner、sibling(兄弟/姐妹)、colleague、parent。",
},
minBudget: {
type: "number",
minimum: 0,
description: "以用户货币计的最低预算。",
},
maxBudget: {
type: "number",
minimum: 0,
description: "以用户货币计的最高预算。",
},
interests: {
type: "array",
items: {
type: "string",
description:
"兴趣的简短名称,例如:videogames、boardgames、books。",
},
minItems: 1,
description: "收礼人的兴趣列表。",
},
},
required: ["relationship", "maxBudget"],
};
这里有几个需要立刻说明的要点。
首先,是字段的 description。在普通 API 中你也许可以不写——前端开发者读了 Swagger 也能理解。 但这里“客户端”是模型,它试图从名称与描述中提炼语义。 你越清晰地说明“年龄(单位:岁)”、“预算(以用户的货币计)”、“enum 为固定取值”,你在运行时看到的奇怪参数就会越少。
其次,enum 是管理模型最强有力的工具之一。 如果你允许模型在 relationship 中写任意字符串,你会收到诸如 “bro”、“girlfriend”、“bestie”、“teammate” 乃至更有创意的内容。 而如果你指定了 enum,模型很大概率只会从这些值中选择。 这能直接减少参数中的“幻觉”。
第三,不是所有字段都必须设为 required。 例如 age 可以是可选的:如果用户没有提供,模型就不会“凭空”编造一个“约莫年龄”(前提是你在描述里这样引导)。 这就是艺术的开始:在灵活与严格之间取得平衡。
现在在注册工具时使用这份模式:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"根据预算、关系类型和收礼人的兴趣推荐礼物创意。",
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
// 此处的 input 已大致符合该模式
// ...
}
);
这种“手写”的对象很适合快速试验。但随着应用增长,它会变成一个容易与 TypeScript 类型“脱节”的独立世界。 稍后我们会回到这个问题,看看如何用 Zod 和从类型生成 JSON Schema 的方式来解决。
3. JSON Schema 也是提示词:如何写 description 让模型不犯错
形式上 JSON Schema 是做校验的;但在 LLM 的世界里,它也是结构化的提示词。这里有一些实践规则:
- 字段 description 必须回答“这里该放什么以及以什么格式放”。
“日期”这种描述没有帮助。“ISO 8601 日期,格式 YYYY-MM-DD,例如 "2025-02-14"”——就非常有用。 - 涉及金额的字段——请明确单位。
最好明确写“金额以用户的货币计”或“金额以美元计”。否则模型可能会认真地写 50,而你会猜不出是 50 日元还是 50 欧元。 - 字符串“类别”几乎总是用 enum 更好。
如果字段是“类别”字符串,最好做成 enum,并在工具的 description 里解释每个值。 例如对 relationship,可以在工具描述中写: “relationship:以下之一:friend(朋友)、partner(恋爱伴侣)、sibling(兄弟或姐妹)、colleague(同事)、parent(父母)。不要编造其他值。” - 数组字段最好设置 minItems 并解释这是什么列表。
如果字段是数组,最好指定 minItems,并简要说明这个列表的含义。 例如,interests 不是“用散文描述一个人”,而是“一组简短的标签”。
这些听起来有些唠叨,但实践中“有描述”和“无描述”的差别,往往就是“稳定应用”和“模型今天又要出什么幺蛾子”的差别。
洞见
MCP 工具有严格的大小限制——这往往是导致“神秘”崩溃、奇怪错误,以及助理突然看不见你的 tools 的罪魁祸首。
关键规则很简单:工具应完整装进约 4 KB 的 JSON。 不仅仅是 description 文本,而是整个结构:
- 工具描述,
- 参数模式(inputSchema),
- 嵌套对象与 enum,
- _meta 与 annotations。
如果你的工具不断膨胀,平台会变得不可预测:会出现诸如 "Tool description is too long"、"Schema validation failed"、"Manifest exceeds size limits" 之类的错误, 有时 ChatGPT 甚至会停止加载该工具,或“忘记”它的存在。
建议:将 description 控制在 1000–2000 字符之内,整个工具控制在“安全”的 ~4 KB 以内。 如果描述变得太长,这几乎总是在提示:这个工具一次做了太多事。 把工具拆得更窄、更清晰——模型会更可靠地理解边界,也更少在输入数据上犯错。
4. TypeScript 和 Zod:一个真相来源,告别双写
手写 JSON 模式对 TypeScript 开发者来说很痛苦。你得同时维护两套“世界”:
- TS 代码中的类型;
- 给模型用的 JSON Schema。
应用越大,它们越容易背离。今天你改了 TypeScript 类型的字段,明天忘了更新模式——下周就在线上踩坑。
在 TS 世界里的事实标准是:使用 Zod,并做 Zod -> JSON Schema 的转换。
安装依赖(如果还没有):
npm install zod zod-to-json-schema
用 Zod 为 suggest_gifts 描述输入模式:
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SuggestGiftsInputZod = z.object({
age: z
.number()
.int()
.min(0)
.max(120)
.describe("收礼人的年龄(岁)。"),
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe(
"关系类型:friend(朋友)、partner(伴侣)、sibling(兄弟/姐妹)、colleague(同事)、parent(父母)。"
),
minBudget: z
.number()
.min(0)
.optional()
.describe("以用户货币计的最低预算。"),
maxBudget: z
.number()
.min(0)
.describe("以用户货币计的最高预算。"),
interests: z
.array(
z
.string()
.min(1)
.describe(
"兴趣的简短标签,例如:videogames、boardgames、books。"
)
)
.min(1)
.describe("收礼人的兴趣列表。"),
});
现在你拥有:
- 运行时校验:SuggestGiftsInputZod.parse(input);
- TypeScript 类型:type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
- 给模型用的 JSON Schema:zodToJsonSchema(SuggestGiftsInputZod)。
在注册工具时使用它们:
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"根据预算、关系类型和收礼人的兴趣推荐礼物创意。",
inputSchema: suggestGiftsInputSchemaJson,
},
async ({ input }) => {
// 这里可以再用 Zod 进行校验:
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
// 接下来使用类型化的 args
}
);
这种方式正好实现了single source of truth——单一事实来源: 你只需描述一次模式,TypeScript 类型与 JSON Schema 会自动生成。
在真实项目里,你还会为此补上测试,以校验 zodToJsonSchema 生成的结构是否符合预期,但这是测试模块的话题了。
洞见:ChatGPT 不擅长处理可选参数
线上最痛的实践之一:一旦你在工具模式里大量使用 optional 字段,tool-call 的质量会明显下滑。 模型理论上“懂得”可选参数是什么,但实际上经常干脆就不发——即便业务逻辑很需要它们。
Response API 把这个问题“优雅地”解决了:直接去掉 optional 字段——工具的所有参数都必须声明为 required。 但问题并未消失:那种“我把一半字段标可选,让模型自己决定填不填”的想法,会撞上现实:它通常什么都不发。
5. “模式”到此为止,“界面设计”从这里开始
前面我们一直在谈 inputSchema——也就是模型为启动工具需要生成的参数。 但工具被调用后,生活还没结束:结果还要在 UI 里渲染出来。
这里把两层分开很有用:
- 工具的模式描述模型需要生成的输入参数。 它始终是 JSON,存在于 MCP / tool-call 的空间。
- UI 组件(小部件)读取 toolOutput.structuredContent,并据此构建界面。 structuredContent 的格式也要你来设计,但它已不再是给模型用的 JSON Schema (当然你也可以自用地把它形式化)。
有时开发者想用一个 JSON 对象“射两只鸟”——既做模型输入,又做 UI 数据格式。往往不会有好结果。 更方便的拆分方式是:
- inputSchema —— 模型需要什么才能启动工具;
- structuredContent —— UI 需要什么来渲染结果。
例如,inputSchema 对于 suggest_gifts 并不包含任何礼物的 id。 而 structuredContent 恰恰包含带有 id、title、price、购买链接等的卡片列表。
6. 注解与 _meta:影响 UX 与安全性
除了参数模式与响应结构,还有一层——平台如何对待工具并将其展示给用户。 这由元数据与注解来负责。
除了标准字段 title、description、inputSchema,工具还可能有额外的元数据与 annotations。 在 Apps SDK 与 MCP 中,有些东西放在 _meta(例如 securitySchemes), 还有些则是 OpenAI 特定的提示字段,比如 readOnlyHint 与 destructiveHint。
重点是:这些注解不会改变 JSON Schema,但会影响 ChatGPT 如何向用户展示工具、以及如何对待它的调用。
示例:readOnlyHint 与 destructiveHint
假设你有两个工具:
- list_gifts —— 仅获取礼物列表(安全);
- create_order —— 创建订单(潜在危险:涉及金钱、地址等)。
你可以把它们这样标注(伪代码):
server.registerTool(
"list_gifts",
{
title: "List gift suggestions",
description: "根据给定的筛选条件获取可用礼物列表。",
inputSchema: listGiftsInputSchema,
_meta: {
readOnlyHint: true,
},
},
async ({ input }) => { /* ... */ }
);
server.registerTool(
"create_order",
{
title: "Create gift order",
description:
"以用户的名义为指定礼物创建订单。仅在得到明确确认后使用。",
inputSchema: createOrderInputSchema,
_meta: {
destructiveHint: true,
},
},
async ({ input }) => { /* ... */ }
);
语义如下: readOnlyHint 向 ChatGPT 表示该工具不会修改任何东西且安全;模型与 UI 可以更自由地调用它。 destructiveHint 表示该工具会做不可逆或关键操作,因此更频繁地需要用户确认,模型也会更谨慎。
在你的 Gift 应用里,suggest_gifts 显然是只读的;而与下单、扣款、修改用户数据相关的工具最好标为具有潜在破坏性(destructive)。
openWorldHint 及类似字段
有时你想提示模型:工具工作于“开放世界”,即其结果并非穷尽。 例如,search_products 永远不可能返回世界上全部商品,而只会返回相关结果。
这样的注解能帮助模型不要下结论“如果在 search_products 中找不到该商品,就说明它不存在”。 这是一种细腻的 UX 处理,但在生产应用中差异非常明显。
围绕 UI 展示的 _meta
当你的工具返回结果时,你可以在 _meta 中进一步指定影响小部件的设置。 比如:使用哪个 HTML 模板作为输出模板、是否需要边框、调用过程中显示什么文案等。
例如,在官方示例中,服务器会将小部件的 HTML 作为 MCP 资源单独注册,然后通过 _meta["openai/outputTemplate"] 引用它。
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description: "推荐礼物创意。",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html", // 这是 MCP 资源的 id:server.registerResource(...)
"openai/toolInvocation/invoking": "正在挑选礼物…", // 调用过程中显示
"openai/toolInvocation/invoked": "找到了礼物选项", // 调用完成后显示
},
},
async ({ input }) => {
// ...
return {
content: [],
structuredContent: { items: gifts },
};
}
);
这样,你在一个地方同时描述了:
- 模型所需的输入数据形式(inputSchema);
- 工具在 UI 中的呈现与行为(_meta)。
7. 模式设计:该向模型要什么,不该要什么
一个常见陷阱是把所有工作都丢给模型。 例如,你在 inputSchema 中放了 giftId,并在 description 写:“来自我们数据库的礼物 UUID”。 模型当然会认真生成类似 "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234" 的 UUID, 问题在于你那边很可能并不存在这个礼物。
一个好规则:不要让模型生成技术标识符及与你内部世界绑定的数据。
更好的方式是做成多步流程:
- suggest_gifts 返回包含 id、title、price 等的礼物列表;
- UI/模型让用户从建议中选择一个;
- create_order 接受来自已有集合的 giftId。
从模式角度看,这意味着:
- 面向“外部”(用户)的工具,其 inputSchema 只描述用户能合理输入的内容:搜索参数、筛选条件、准则;
- 操作内部实体的工具,其 inputSchema 依赖已知的 id,而不是要求模型“编造”它们。
对于你的 Gift 应用,这意味着在 suggest_gifts 里不要让模型“想出 SKU 代码”,而只提供查询参数。 SKU 本身在后端侧处理,UI 再显示给用户。
说明:SKU 是商品的库存单位代码(国际通用的唯一标识)。示例 "GFT-CHC-500-BS"。
8. 小型实践:把所有内容串起来
让我们把上面谈到的内容放到一起:Zod 模式、JSON Schema 生成、带 _meta 的工具注册以及在业务逻辑中的使用。 为 Gift 应用做一个小而完整的示例。
先写 Zod 模式与类型:
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SuggestGiftsInputZod = z.object({
relationship: z
.enum(["friend", "partner", "sibling", "colleague", "parent"])
.describe("与收礼人的关系类型。"),
maxBudget: z
.number()
.min(0)
.describe("以用户货币计的最高预算。"),
interests: z
.array(
z
.string()
.min(1)
.describe("简短的兴趣标签,例如:videogames。")
)
.min(1)
.describe("收礼人的兴趣列表。"),
});
type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
const suggestGiftsInputSchemaJson = zodToJsonSchema(
SuggestGiftsInputZod,
"SuggestGiftsInput"
);
接着——注册带有 _meta 的工具用于 UI:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"当需要根据预算、关系与兴趣来推荐礼物创意时使用。",
inputSchema: suggestGiftsInputSchemaJson,
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
"openai/toolInvocation/invoking": "正在挑选礼物…",
"openai/toolInvocation/invoked": "找到了礼物选项",
readOnlyHint: true,
},
},
async ({ input }) => {
const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;
const gifts = await findGifts(args); // 你的业务逻辑
return {
content: [],
structuredContent: {
items: gifts,
},
};
}
);
旁边会有一个类型化的业务函数:
async function findGifts(input: SuggestGiftsInput) {
// 这里可以使用 input.relationship、input.maxBudget、input.interests
// 并返回一个 Gift 类型的对象数组
return [
{
id: "gift-1",
title: "电子游戏主题的桌游",
price: 45,
currency: "USD",
},
];
}
在小部件一侧,你随后会读取 window.openai.toolOutput.structuredContent.items 并渲染卡片,不过这个会在后面的课程中详细介绍。
9. 描述工具时的常见错误
错误 1:字段描述过于笼统或无意义。
如果你写 description: "日期" 或 description: "筛选参数",模型几乎得不到任何有用信息。 这就像文档里写“该方法做了一些重要的事”一样。 请使用能回答“这里该放什么”“格式是什么”的描述。 例如:“ISO 8601 日期,格式 YYYY-MM-DD,如 "2025-02-14"”或“金额以用户的货币计,示例:49.99”。
错误 2:该用 enum 的地方没有用。
开发者常常懒得把字符串改成 enum,而是保留 type: "string"。 结果就是模型编造自己的取值、后端懵逼、UI 崩坏。 如果有固定集合(如 relationship、状态类型、排序方式)——几乎总该做成 enum 并列出可能的取值。 这能显著提升 tool-call 的可预测性。
错误 3:模式与类型有两个真相来源。
经典错误:你在 TypeScript 把 maxBudget 改成了 priceMax,却忘了更新 JSON Schema。 模型继续发 maxBudget,代码却期待 priceMax,然后一切都挂了。 这类错误常在生产才被发现。 因此最好一开始就使用 Zod 或类似工具,从一份声明同时生成类型与 JSON Schema。
错误 4:让模型生成内部标识符。
诸如 userId、giftId、orderId 等字段,如果你把它们描述为“我们系统中的用户 UUID”,模型不可避免地会填入杜撰的值。 即便你为 UUID 加了 pattern,模型也只会生成“看起来正确”的 UUID,却毫无对应关系。 这类字段最好在后端按上下文(鉴权、前一个 tool-call)填充,而不是让模型来做。
错误 5:巨大无比、企图包打天下的“上帝模式”。
有时会想整一个 do_everything 的工具,里面对象超大,一半字段 nullable,一半 optional。 模型会在其中迷失。 请把功能拆成多个更窄、更易懂的工具: 一个负责找礼物,另一个负责获取具体礼物详情,第三个负责创建订单。
错误 6:忽略 _meta 与 annotations。
很多开发者只用 name、description 与 inputSchema, 却漏掉了 _meta 中的字段,如 openai/outputTemplate,以及像 destructiveHint 这样的提示。 结果就是那些“默默”做危险动作的工具,在 UI 中既没有提示也没有确认。 这会降低用户信任,并带来意外操作的风险。 使用 annotations 来明确标记只读与危险工具,同时设置友好的执行状态文案。
错误 7:服务端缺少输入校验。
即使 JSON Schema 与 Zod 看起来描述充分,仅依赖模型依然有风险。 模型有时会给出部分有效的数据,或者你自己改了模式却忘记了业务约束。 在处理器外包一层 try { parse } catch { ... } 并返回友好错误,能给模型一次修正参数的机会,也能避免一次失败的 tool-call 拖垮整个服务。
GO TO FULL VERSION