iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

網站終於會說話:30 天實作並驗證 Agent-ready 的 WebMCP 活動網站系列 第 12

Day 12|Tool 註冊完還不算完成:離開 route 後誰把它收回來?

  • 分享至 

  • xImage
  •  

安安~我是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,好讓註冊、解除、去重與失敗恢復可以被單獨觀察。

Imperative API 由 JavaScript 明確定義 Tool

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 出現或移除 由程式註冊與解除

知道怎麼註冊只是前半段。現在要回答的,是目前頁面不再需要它時,誰負責請它離場。

route 切換時的 Imperative Tool 生命週期

圖 1:Lab 用 list 與 about 隔離生命週期問題。離開有效情境後,Tool 必須解除;回來時才重新註冊。

Registry 接收「現在應該存在什麼」,不是「再加什麼」

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。

隱藏 UI 不等於撤銷能力

活動卡片或側邊面板消失,只代表人類暫時看不到。只要 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 重跑兩次,不能註冊兩支同名 Tool

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() 還有機會
成功。這不是吞掉錯誤,而是避免一次失敗把整個生命週期永久鎖死。

用四個斷言驗收 Registry,而不是只看畫面

這個 Lab 的 focused tests 主要檢查:

  1. 同時送出兩次相同 sync(),register 仍只發生一次。
  2. 第一次 register 失敗時,呼叫端確實收到錯誤。
  3. 第二次 sync() 可以恢復並成功註冊。
  4. Desired set 變成空清單後,對應 controller 已被 aborted。

網站啟動後,也可以手動走一次:

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 為準。

Result 契約先留最小版本,今天不往外岔題

Lab 另外保留最小的成功與失敗結果:

success({ id: "evt-1" });
failure("VALIDATION_ERROR", "BAD_INPUT");
failure("TEMPORARY_FAILURE", "API_UNAVAILABLE");

目的只是讓呼叫端不必從任意 error message 猜測是否能重試。完整的 result 欄位、資料白名單與
錯誤分類,會在後面另外處理;今天先把主題停在 Tool 何時存在,以及何時必須失效。

今天證明的是生命週期,不是 Agent 自主呼叫

這輪測試能證明 registry 的註冊、去重、解除與失敗恢復可以穩定重播,證據停在 E2。它沒有
證明真實 Agent 已經從自然語言選中 get_event_details,測試用的 register callback 也不能
冒充正式 discovery。

但地基至少整理乾淨了。昨天讓靜態表單能描述自己,今天則讓動態能力知道什麼時候該下班。
明天第一次按下 Inspector 的 Send,不從下拉選單替 Agent 指定 Tool,只送一句自然語言。
如果先收到的不是活動,而是另一個環境錯誤,也會照原樣留下。

參考資料


上一篇
Day 11|什麼是 Declarative API?讓 HTML 表單成為 WebMCP Tool
下一篇
Day 13|第一次按下 Inspector 的 Send,先被 Gemini 429 擋在門外
系列文
網站終於會說話:30 天實作並驗證 Agent-ready 的 WebMCP 活動網站23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言