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 等等。
也就是說,你不只是保護 backend 免於不良資料,更是在向 AI 模型解釋如何正確呼叫你的函式。
2. 工具 suggest_gifts 的基本 JSON Schema
先從簡單的開始。假設有以下情境:
使用者輸入:
「幫 25 歲的弟弟挑個禮物,預算 50–70 美元,喜歡電玩與桌遊」。
工具 suggest_gifts 需要接收大致如下的參數:
- 收禮者年齡;
- 關係類型(兄弟、同事、伴侶等);
- 最低與最高預算;
- 興趣清單。
先不用 Zod,而是直接用「純物件」把它寫成 JSON Schema:
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 可以不必填:若使用者沒提供,模型就不會「憑空」捏造一個「大概年齡」(前提是你在說明中這樣表述)。 這就是藝術所在:在彈性與嚴格之間取得平衡。
現在把這份 schema 用在註冊工具裡:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
description:
"根據預算、關係類型與收禮者的興趣,提出禮物點子。",
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
// 這裡的 input 已大致符合 schema
// ...
}
);
這種「手寫」物件很適合快速實驗。但隨著應用成長,它會和你的 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 不是「以散文描述一個人」,而是一組「簡短的標籤」。
這些聽起來有點囉嗦,但實務上,有沒有寫清楚說明,差別就是:穩定的應用,與每天都在抽獎看模型今天會傳什麼。
Insight
MCP 工具有嚴格的大小限制——而這往往是導致「神秘」崩潰、怪錯誤,以及助理突然看不見你的工具的主因。
關鍵規則很簡單:整個工具(序列化成 JSON)應控制在約 ~4 KB。 這不只包含 description 的文字,而是整個結構:
- 工具的描述,
- 參數的 schema(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‑schema 對 TypeScript 開發者是種痛苦。你得維護兩個平行世界:
- TS 程式碼中的型別;
- 給模型用的 JSON Schema。
隨著應用成長,它們會開始走樣。今天你改了 TypeScript 型別,明天忘了更新 Schema——一週後在 prod 才踩雷。
事實上的標準做法是:使用 Zod,並把 Zod -> JSON Schema。
先安裝相依(若尚未安裝):
npm install zod zod-to-json-schema
用 Zod 定義 suggest_gifts 的輸入 schema:
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 是否產生了預期的結構,但這是測試模組的主題。
Insight: ChatGPT 對可選參數處理不佳
在 prod 中最痛的經驗之一:一旦你在工具的 schema 大量使用 optional 欄位,tool‑calls 的品質就會明顯下降。 模型理論上「理解」可選參數,但實務上常常乾脆不傳——即使依商業邏輯你很需要。
Response API 以漂亮的方式規避了這點:它乾脆移除 optional 欄位——所有工具參數都必須宣告為 required。 但問題不會自動消失:那種「我把一半欄位標 optional,讓模型自己決定要不要填」的想法,通常只會換來它什麼都不填。
5. 哪裡是「schema」的邊界,哪裡開始是「介面設計」
前面我們一直在談 inputSchema——也就是模型為了啟動工具需要生成的參數。 但工具被呼叫之後還沒結束:結果還要在 UI 上呈現。
在這裡,分清兩個層次很有幫助:
- 工具的 schema 描述的是模型需要生成的輸入參數。 這永遠是存在 MCP / tool‑call 空間中的 JSON。
- UI 元件(小工具)會讀取 toolOutput.structuredContent,並在此基礎上建構介面。 structuredContent 的格式也由你設計,但它不是給模型用的 JSON Schema (當然你可以在團隊內部把它形式化)。
有時開發者想用一個 JSON 物件同時解決兩件事——既當模型的輸入,又當 UI 的資料格式。這通常下場不好。 比較好的做法是分開:
- inputSchema —— 給模型啟動工具所需;
- structuredContent —— 給 UI 繪製結果所需。
例如,inputSchema 對 suggest_gifts 不包含任何禮物的 id。 而 structuredContent 則相反——它包含卡片清單,內有 id、title、price、購買連結等。
6. Annotations 與 _meta:如何影響 UX 與安全
除了參數 schema 與回應結構,還有另一層——平台如何對待你的工具並把它展示給使用者。 這一層由中繼資料與 annotations 負責。
除了標準欄位 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 顯然是 read‑only,而建立訂單、扣款與修改使用者資料的工具則適合標註為可能 destructive。
openWorldHint 與相似欄位
有些情況你想提示模型,工具運作在「開放世界」,也就是結果不是窮舉的。 例如,search_products 永遠不會回傳世界上所有商品,只會回傳相關的。
這類註記能幫助模型不要下重結論,例如「如果在 search_products 找不到某商品,就代表它不存在」。 這是細膩的 UX 面向,但在正式環境差異很明顯。
_meta 與 UI 呈現
當你的工具回傳結果時,可以在 _meta 中加入進一步會影響小工具外觀的設定。 例如:使用哪個 HTML 樣板作為 output‑template、是否需要邊框、呼叫期間顯示哪段文案等。
例如,在官方範例中,伺服器會把小工具的 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. Schema 設計:該向模型要什麼,不該要什麼
常見陷阱之一——試著把所有工作都丟給模型。 例如,你在 inputSchema 裡放了 giftId,並在 description 寫:「我們資料庫的禮物 UUID」。 模型當然會試著生成類似 "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234" 的 UUID, 問題是,你的系統裡很可能根本沒有這個禮物。
好原則:不要請模型生成與你內部世界綁定的技術性識別碼與資料。
改成多步驟流程比較好:
- suggest_gifts 回傳帶有 id、title、price 等資訊的禮物清單;
- UI/模型讓使用者從建議中選擇;
- create_order 接收已存在集合中的 giftId。
就 schema 而言,這意味著:
- 面向「外部」(使用者)的工具,其 inputSchema 只描述人類能理性提供的內容:搜尋參數、篩選條件、準則;
- 操作內部實體的工具,其 inputSchema 依賴已知的 id,而不是要求模型去編造。
對你的 Gift 應用來說,這表示在 suggest_gifts 裡不要叫模型「編一個 SKU」,而只請它提供查詢參數。 SKU 會由 backend 端接上,UI 再顯示給使用者。
備註:SKU 是商品的唯一代碼。範例 "GFT-CHC-500-BS"。
8. 小實作:把一切串起來
讓我們把上面提過的:Zod‑schema、JSON Schema 產生、帶有 _meta 的工具註冊,以及在商業邏輯中的使用, 整合成一個最小但完整的 Gift 應用範例。
先是 Zod‑schema 與型別:
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 的工具註冊:
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‑calls 的可預測性。
錯誤 #3:Schema 與型別有兩套真相。
經典案例:在 TypeScript 把 maxBudget 改成 priceMax,卻忘了更新 JSON Schema。 模型還在傳 maxBudget,程式碼期待 priceMax,然後一切崩潰。 這類錯誤常在 prod 才被發現。 因此最好一開始就使用 Zod 或類似工具,從單一宣告同時產生型別與 JSON Schema。
錯誤 #4:請模型生成內部識別碼。
像 userId、giftId、orderId 這類欄位,如果你描述成「我們系統中的使用者 UUID」,模型不可避免地會用編造的值填上。 即便你加了 UUID 的 pattern,模型只會開始產生「看起來正確」的 UUID,卻毫無對應。 這種欄位最好根據情境在 backend 端填入(驗證、先前的 tool‑call),而不是請模型去填。
錯誤 #5:巨無霸的「萬能」schema。
有時很想做一個 do_everything 工具,配一個巨大的物件,一半 nullable、一半 optional。 模型會在其中迷失。 把功能拆成數個工具、各自更窄且清楚: 一個負責搜尋禮物,另一個拿特定禮物細節,第三個負責建立訂單。
錯誤 #6:忽略 _meta 與 annotations。
許多開發者只使用 name、description 與 inputSchema, 卻忽略了 _meta 中如 openai/outputTemplate 的欄位,以及像 destructiveHint 之類的提示。 結果是工具會「默默」進行危險動作,UI 沒有適當的提示與確認。 這會降低使用者信任,並帶來意外操作的風險。 請使用 annotations 來明確標記 read‑only 與危險的工具,並設定友善的執行狀態文案。
錯誤 #7:伺服器端缺乏輸入驗證。
即使 JSON Schema 與 Zod 看似都描述完了,只仰賴模型仍然有風險。 有時模型會輸出部分有效的資料,或你自己改了 schema,卻忘了商業限制。 把處理器包在 try { parse } catch { ... } 中並回傳友善錯誤,能讓模型有機會修正參數,也能避免一次不良的 tool‑call 讓整個服務倒下。
GO TO FULL VERSION