安安~我是ChiYu~
昨天的 Declarative API 很適合表單:HTML 還在,Tool 就跟著存在;表單離開頁面,能力也一起
消失。兩邊的生命週期幾乎黏在一起。
活動詳情就沒這麼省事了。它要讀取目前活動、跟著 route 切換情境,離開頁面後還得把舊能力
收回來。
我第一次實作時只記得:
await document.modelContext.registerTool(detailsTool);
Tool 的確註冊成功,但我完全沒處理解除。結果畫面早已切到別頁,舊 handler 還握著上一場
活動的 ID,像一位已經換班、鑰匙卻還放在口袋裡的值班人員。
所以今天真正要處理的不是「Imperative API 怎麼多註冊一支 Tool」,而是完整生命週期:它在
什麼情境出現、離開時怎麼失效、同一個 route 重跑時如何避免重複,以及第一次註冊失敗後
能不能恢復。
今天使用 v3-day-12:
git switch --detach v3-day-12
npm ci
npm run dev
接著開啟:
http://127.0.0.1:5173/labs/day-11-tool-lifecycle/index.html?route=list
資料夾仍保留早期開發名稱,但本文的版本座標是 v3-day-12。這個 Lab 不急著模擬完整活動
網站,只留下兩個 route,好讓註冊、解除、去重與失敗恢復可以被單獨觀察。
Declarative API 從 HTML form 產生契約;Imperative API 則由 JavaScript 提供名稱、用途、
input schema、annotation 與執行 callback,再註冊到目前 Document 的 model context。
今天的最小 Tool 長這樣:
const detailsTool = {
name: "get_event_details",
description: "依活動 ID 讀取公開活動資訊。",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["event_id"],
properties: {
event_id: {
type: "string",
description: "公開活動 ID。"
}
}
},
annotations: {
readOnlyHint: true,
untrustedContentHint: true
},
execute: async (input) => ({
ok: true,
eventId: input.event_id
})
};
直接使用瀏覽器 API 時,註冊動作是:
await document.modelContext.registerTool(detailsTool);
專案沒有讓每個頁面自行散落這行程式,而是包進 ToolRegistry 與 model-context adapter。
這層不是另一套 WebMCP;它只負責把 route 切換、解除、重複註冊與失敗恢復集中管理。
Chrome 官方文件已註記 navigator.modelContext 自 Chrome 150 起棄用,因此本文統一使用document.modelContext。
昨天和今天的差別,可以收成這張表:
| 比較項目 | Declarative API | Imperative API |
|---|---|---|
| 契約來源 | HTML form 與欄位標註 | JavaScript Tool 物件 |
| 適合情境 | 搜尋、篩選與既有表單 | 動態內容、非表單操作與 route context |
| input schema | 瀏覽器依表單語意整理 | 開發者明確撰寫 |
| 生命週期 | 通常跟著 form 出現或移除 | 由程式註冊與解除 |
知道怎麼註冊只是前半段。現在要回答的,是目前頁面不再需要它時,誰負責請它離場。

圖 1:Lab 用 list 與 about 隔離生命週期問題。離開有效情境後,Tool 必須解除;回來時才重新註冊。
Lab 只保留兩個 route:
list:可以公開 get_event_details。about:不應公開這支 Tool。每次 route 改變,我不再叫 registry「新增一支 Tool」,而是交給它目前完整的 desired state:
await registry.sync(
route === "list" ? [detailsTool] : []
);
這個差異看起來小,後果很大。只會做加法的 registry 會一路累積歷史能力;desired-state
版本則能比較「現在有什麼」和「現在應該有什麼」,該補的補上,該離場的解除。
for (const [name, controller] of this.controllers) {
if (!desired.has(name)) {
controller.abort();
this.controllers.delete(name);
}
}
Registry 保存的因此不是「這個網站曾經註冊過什麼」,而是「目前頁面允許 Agent 使用什麼」。
這個觀念後面會直接用在正式 route-aware catalog。
活動卡片或側邊面板消失,只代表人類暫時看不到。只要 Tool 還留在 model context,Agent 仍可能
發現並呼叫它。
UI 隱藏與能力撤銷是兩件事。Chrome 的 Imperative API 文件
提供的解除方式,是註冊時帶入 AbortSignal,離開情境時再 abort():
const controller = new AbortController();
await document.modelContext.registerTool(tool, {
signal: controller.signal
});
controller.abort();
專案替每支已註冊 Tool 保存一個 controller。新 desired set 不再包含它時,registry 先 abort,
再從本地 map 移除,讓瀏覽器與應用程式看見同一份狀態。
這也是為什麼我不只在畫面上寫「目前無此功能」。真正的能力邊界必須反映在 catalog,不能靠
CSS 幫忙演下班。
Route 更新可能連續發生。兩次 sync() 若同時進來,都可能在註冊前看到「目前還沒有get_event_details」,接著各自新增一次。
ToolRegistry 因此用 Promise queue 讓更新依序套用,並以 Tool name 去重:
sync(tools: ProjectTool<object, unknown>[]): Promise<void> {
const operation = this.queue.then(() => this.apply(tools));
this.queue = operation.catch(() => undefined);
return operation;
}
這裡還有一個容易被忽略的恢復問題。第一次 register 若暫時失敗,呼叫端應該收到錯誤;但
registry 內部的 queue 不能從此維持 rejected,不然後面每次 route 更新都會被同一個舊錯誤
擋住。
因此程式會保留當次失敗,同時把內部 queue 接回可工作的狀態,讓下一次 sync() 還有機會
成功。這不是吞掉錯誤,而是避免一次失敗把整個生命週期永久鎖死。
這個 Lab 的 focused tests 主要檢查:
sync(),register 仍只發生一次。sync() 可以恢復並成功註冊。網站啟動後,也可以手動走一次:
list
→ Active project Tools: get_event_details
about
→ Active project Tools: none
list
→ Tool 重新註冊,Register calls = 2
接著執行:
npm test -- tests/unit/day-11-registry.test.ts tests/unit/day-11-result.test.ts
npx playwright test tests/browser/day-11-lifecycle.spec.ts
固定版本中的程式與測試可以從這裡查看:
目前 main 另有較好找的讀者入口labs/day-12-imperative-lifecycle/index.html?route=list,但 main 會持續累積後續改動;本文
操作與測試結果仍以 v3-day-12 為準。
Lab 另外保留最小的成功與失敗結果:
success({ id: "evt-1" });
failure("VALIDATION_ERROR", "BAD_INPUT");
failure("TEMPORARY_FAILURE", "API_UNAVAILABLE");
目的只是讓呼叫端不必從任意 error message 猜測是否能重試。完整的 result 欄位、資料白名單與
錯誤分類,會在後面另外處理;今天先把主題停在 Tool 何時存在,以及何時必須失效。
這輪測試能證明 registry 的註冊、去重、解除與失敗恢復可以穩定重播,證據停在 E2。它沒有
證明真實 Agent 已經從自然語言選中 get_event_details,測試用的 register callback 也不能
冒充正式 discovery。
但地基至少整理乾淨了。昨天讓靜態表單能描述自己,今天則讓動態能力知道什麼時候該下班。
明天第一次按下 Inspector 的 Send,不從下拉選單替 Agent 指定 Tool,只送一句自然語言。
如果先收到的不是活動,而是另一個環境錯誤,也會照原樣留下。