安安~我是ChiYu~
昨天介紹的 Declarative API 很適合表單:只要替既有 HTML 補上標註,瀏覽器就能整理出
Tool 契約。表單待在頁面上,Tool 也跟著待在頁面上,兩邊的生命週期幾乎黏在一起。
但活動詳情不是一張等著送出的表單。它要讀取目前頁面的活動、跟著 route 切換情境,離開
頁面後還得把舊能力收回來。這種需要由 JavaScript 主動決定 Tool 何時存在的工作,就輪到
Imperative API 上場了。
今天要做的 get_event_details 只在特定頁面情境下有意義。使用者離開後,這支 Tool 理論上
也該一起退場;不然畫面早已切走,舊 handler 卻還握著上一場活動的 ID,Inspector 的 Tool
清單也可能繼續把它當成可用能力。
我第一次實作時只記得 registerTool(),完全沒想過要解除。結果它很像一位已經換班,卻
還把鑰匙放在口袋裡的值班人員:人走了,權限沒交回來。
所以今天不只介紹 Imperative API 怎麼註冊 Tool,也要把後半段補完整:它何時出現、何時
不能再被發現,以及回到有效頁面時,應該重新註冊還是沿用舊 handler。
今天的程式里程碑是 v3-day-12。這個版本在昨天的 Declarative 表單之外,加入ToolRegistry、AbortSignal 與可重播的 lifecycle Lab。先切到固定版本並啟動網站:
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。今天要驗證的不是正式活動
詳情頁,而是更基礎的問題:Tool 能否隨 route 正確註冊、解除、去重,並在暫時失敗後恢復。
Declarative API 是在既有 HTML form 上加標註;Imperative API 則由 JavaScript 明確定義
一支 Tool 的名稱、用途、輸入格式與執行行為,再透過 document.modelContext.registerTool()
註冊到目前頁面的 model context。
它適合處理的不只有讀取資料,也包括頁面導覽、狀態管理,以及其他難以用單一表單描述的
操作。以今天的 Lab 來說,get_event_details 的最小結構可以整理成這樣:
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 ?? null
})
};
如果直接使用瀏覽器 API,註冊動作會是:
await document.modelContext.registerTool(detailsTool);
專案沒有讓每個頁面各自呼叫這行,而是包進 ToolRegistry 與 model-context adapter。這層
包裝不是要發明另一套 WebMCP,而是集中處理 route 切換、重複註冊、解除與失敗恢復。Chrome
官方文件也已註記:navigator.modelContext 自 Chrome 150 起棄用,因此本文統一使用document.modelContext。
把昨天與今天放在一起看,兩種 API 的分工會清楚很多:
| 比較項目 | Declarative API | Imperative API |
|---|---|---|
| 契約來源 | HTML form 與欄位標註 | JavaScript Tool 物件 |
| 適合情境 | 搜尋、篩選與既有表單 | 動態內容、非表單操作與 route context |
| input schema | 瀏覽器依表單語意整理 | 開發者明確撰寫 |
| 生命週期 | 通常跟著 form 出現或移除 | 由程式註冊與解除 |
知道怎麼把 Tool 註冊進去後,真正麻煩的才正要開始:目前頁面已經不需要它時,誰負責請它
離場?

圖 1:Lab 用 list 與 about 兩個 route 隔離生命週期問題。離開有效情境後,Tool 必須從目前清單移除;回來時才重新註冊。
為了先把問題縮小,Lab 只保留兩個 route:
list:代表 get_event_details 可以存在的有效情境。about:代表不該公開這支 Tool 的頁面。我沒有讓 ToolRegistry 接收「請再新增哪些 Tool」,而是每次都交給它「現在應該存在的
完整清單」:
await registry.sync(route === "list" ? [detailsTool] : []);
這個差異很重要。若 sync() 只會做加法,使用者每切一次頁面,registry 就多留一段舊
情境;改成 desired state 後,它可以拿目前狀態與目標狀態比較,該補的補上,該離場的就
解除。
for (const [name, controller] of this.controllers) {
if (!desired.has(name)) {
controller.abort();
this.controllers.delete(name);
}
}
換句話說,registry 保存的不是「這個網站曾經註冊過什麼」,而是「這個頁面現在允許
Agent 使用什麼」。
知道怎麼註冊 Imperative Tool 後,下一個問題不是「還能再註冊幾支」,而是使用者離開目前
情境時,誰負責把它收回來。
把活動卡片或側邊面板隱藏,只代表人類暫時看不到。只要 Tool 還留在 model context,Agent
就仍可能 discovery 到它。UI 消失和能力撤銷,是兩件不同的事。
Chrome 的 Imperative API 文件
提供的做法,是在註冊時傳入 AbortSignal,需要解除 Tool 時再呼叫 abort():
const controller = new AbortController();
await document.modelContext.registerTool(tool, { signal: controller.signal });
controller.abort();
專案把這段行為包進 registry。每一支已註冊 Tool 都有自己的 controller;route 改變後,
不在 desired set 裡的項目會先 abort,再從本地 map 移除。這樣瀏覽器端與應用程式端看見的
狀態才不會各說各話。
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 暫時失敗,queue 不能從此維持 rejected,否則
之後每次 route 更新都會被同一個舊錯誤擋住。呼叫端仍會收到當次失敗,但 registry 會把
內部 queue 接回可繼續工作的狀態,下一次 sync() 才有機會恢復。
Unit test 會同時送出兩次相同的 sync(),確認 register 只發生一次;接著故意讓第一次
register 失敗,再驗證第二次可以成功,最後切成空清單時 controller 確實已經 aborted。
生命週期穩定後,我也替 Lab 留下一個最小結果契約。成功時回傳 SUCCESS 與資料;輸入錯誤
不該叫 Agent 重試,暫時性服務失敗則可以再試一次:
success({ id: "evt-1" });
failure("VALIDATION_ERROR", "BAD_INPUT");
failure("TEMPORARY_FAILURE", "API_UNAVAILABLE");
這三行看起來很樸素,卻比直接 throw new Error() 更容易接手。呼叫端不用從錯誤訊息猜測
下一步,測試也能明確驗證 retryable 是 true 還是 false。後面把 Tool 接上正式 API
時,我們會沿用這個方向,把更多狀態補完整。
網站啟動後,依序切換:
List route:畫面顯示 Active project Tools: get_event_details。About route:active tools 變成 none。List route:Tool 重新註冊,Register calls 應為 2。開發伺服器保持運作,再開一個終端機執行 focused tests:
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
你也可以直接查看固定版本中的
lifecycle Lab、
registry unit test
與 browser test。
目前 main 另外提供較好找的讀者入口labs/day-12-imperative-lifecycle/index.html?route=list,但 main 會繼續累積後續改動;本文的
操作與測試結果,仍以開頭切好的固定 tag 為準。
這輪測試能證明 registry 的註冊、去重、解除與失敗恢復都可以穩定重播,證據等級是 E2。
它沒有證明真實瀏覽器 Agent 已經從自然語言選中 get_event_details,也不能把測試用的
register callback 當成正式 discovery 紀錄。
但地基至少整理乾淨了。昨天讓靜態表單能描述自己,今天則讓動態能力知道什麼時候該下班。
明天我們會打開 Inspector,不從下拉選單替 Agent 指定 Tool,只送一句自然語言搜尋。到時候
要看的不只是結果有沒有回來,還要確認模型究竟選了哪支 Tool;如果先收到的不是活動,而是
一個完全不同的錯誤,也會照實留下來。