CodeGym /課程 /ChatGPT Apps /MCP 中的事件模型:通知類型、訊息格式、冪等性

MCP 中的事件模型:通知類型、訊息格式、冪等性

ChatGPT Apps
等級 13 , 課堂 0
開放

1. 為什麼 MCP 需要事件

到目前為止,ChatGPT 與你的後端之間的互動幾乎都是 RPC:模型呼叫工具,工具做點事、回傳結果——完事。只要操作很短(200–500 毫秒,最多幾秒),這很方便。

但一旦出現長時任務——例如為 GiftGenius 分析一個很大的員工偏好檔案、整合多個外部 API 的推薦、重新計算大型 feed——事情就變得不愉快。HTTP 逾時、函式重啟、無止盡的 spinner,而使用者坐在那裡猜:「它還活著嗎,還是已經掛了?」

這時就需要事件模型。與其維持一次很長的工具呼叫,不如啟動一個任務、取得 jobId,接著伺服器主動推送事件:已開始、進度中、完成、失敗。這些事件在 MCP 中以 JSON-RPC notifications 實作——沒有 id 的單向訊息,不需要回應。

重要的是:事件不是「掛在線上的 console.log」。它是具有特定結構的正式協定訊息,你的 UI(小工具)和/或代理應像處理工具呼叫結果一樣嚴謹地處理它。

提醒:MCP 中的訊息類型

在繼續前,先快速回顧 MCP 中到底有哪些訊息。

撇開所有包裝,MCP 建立在 JSON-RPC 2.0 之上。那裡有三種基本訊息:請求、回應與通知。

與其逐條列出,我們看一下簡短的比較表:

類型 欄位 id 由誰發起 是否預期回應? MCP 中的例子
Request 通常是客戶端(ChatGPT) 呼叫工具 tools/call
Response MCP 伺服器 這本身就是回應 tools/call 的結果
Notification 客戶端或伺服器 notifications/progressresources/updatedlogging/message

MCP 事件正位於第三列:notifications。其特徵:

  • 最上層沒有 id——因此不會有 resulterror 回應;
  • 發起方不等待 ACK——在協定層面「發出即忘」;
  • 可靠性不是靠確認,而是靠處理器的冪等性與重送策略。

一個重要限制:MCP 事件不會「隨時在宇宙中亂飛」。它們存在於既有的 MCP 連線中,基於特定傳輸之上。多數情況是類似 SSE 的串流(傳輸細節與變體我們會在另一堂課討論)。

2. 實務中什麼是「MCP 事件」

形式上,MCP 事件就是 JSON-RPC notification,也就是如下的物件:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "jobId": "job_123",
    "percentage": 30,
    "stage": "在目錄中尋找選項",
    "eventId": "evt_abc123",
    "timestamp": "2025-11-21T10:15:00Z"
  }
}

這裡有幾個關鍵點:

  1. method 欄位中,我們編碼事件類型與其「命名空間」。MCP 已定義了若干標準的 notifications/... 方法(用於日誌、進度與資源變更),但你可以且應該新增自己的商務事件方法,例如 notifications/job/progressnotifications/job/completed
  2. 所有商務資料位於 params。我們也會在那裡放置任務識別碼(jobId)、事件唯一 id(eventId)、時間(timestamp)、人類可讀訊息等。
  3. 最上層缺少 id 欄位——這就是 notification。協定不提供回應。如果伺服器想知道「對方是否理解」,它可以再發送一個事件,或等待客戶端的反應行為(例如新請求)。但在 JSON-RPC 意義上並沒有 ACK。

在心智模型層面可以這樣想:呼叫工具 tools/call 是「一封你期待回覆的信」,而事件是來自 Slack 機器人的通知:「背景任務 #123 已完成」。

3. 事件的分類學:有哪些通知

如果只是放任「隨便把 JSON 當 notifications 發」,兩週後系統會變成垃圾堆:事件命名各不相同、欄位漂移、UI 不知道該怎麼處理。因此,約定一個小而清晰的分類很有幫助。

下面是一種實用的分類方式,能與 MCP 規範及 ChatGPT Apps 的實際案例很好地對齊。

任務生命週期事件(Job Lifecycle)

這類事件反映任務狀態的關鍵轉換。通常任務有一個狀態機(state machine),例如 pendingrunning →(completed | failed | canceled)。

典型事件:

  • job.created — 任務已註冊;
  • job.started — worker 已開始執行;
  • job.completed — 任務成功完成;
  • job.failed — 任務因錯誤失敗;
  • job.canceled — 任務被使用者取消。

job.completed 在 GiftGenius 中的例子:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/completed",
  "params": {
    "eventId": "evt_gg_100",
    "jobId": "giftjob_42",
    "timestamp": "2025-11-21T10:20:00Z",
    "summary": "禮物挑選已完成",
    "resultResourceId": "resource:gifts:giftjob_42"
  }
}

這裡的 resultResourceId 可以指向某個 MCP 資源,之後由小工具或代理讀取。

進度事件(Progress Updates)

這是生命週期中的「細項步驟」:它們不會改變最終狀態,但讓使用者知道確實有在進行。

典型的 job.progress 事件:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "eventId": "evt_gg_101",
    "jobId": "giftjob_42",
    "timestamp": "2025-11-21T10:18:30Z",
    "percentage": 40,
    "stage": "依預算篩選禮物",
    "etaSeconds": 25
  }
}

關鍵在於 percentage 應朝 100 合理遞增,而不是跳來跳去。為進度欄位選定一個名稱(例如 percentage),並在所有事件中一致使用。MCP 官方進度工具也有同樣原則:進度只會增加。

資料/資源更新事件(Resource/Data events)

有時使用者不在乎特定的 jobId,更重要的是某個實體改變了:商品 feed 更新、報表的新快照已產生、個人化檔案已重新生成。

MCP 已提供標準層級的通知,如 resources/updatedresources/list_changed 等,用來提醒客戶端:「重新讀取資源清單,有變更發生」。

在 GiftGenius 中可能如下:

{
  "jsonrpc": "2.0",
  "method": "resources/updated",
  "params": {
    "eventId": "evt_feed_17",
    "timestamp": "2025-11-21T09:00:00Z",
    "resourceId": "resource:product-feed",
    "changeType": "snapshot_ready"
  }
}

小工具收到此事件後,例如可以高亮「更新禮物清單」按鈕。

UX 與系統事件

還有一些不是嚴格商務導向,但對 UX 或診斷很重要的事件:

  • 日誌訊息 logging/message——MCP 內建的日誌通知;
  • heartbeat/ping——伺服器週期性的「我還活著」訊號;
  • 降級警示:例如「目前外部 API 變慢,結果可能延遲」。

這些事件對監控與除錯很有用;有時也可以在 UI 中以較輕量的方式呈現,讓人知道系統沒有掛掉,只是忙碌。

4. 事件結構:必填欄位與 payload

事件和工具請求一樣,都是 API 物件,需要設計。良好習慣是先約定一個基本欄位集合。

觀念上把事件分成三部分很有用:中介資料、關聯(correlation)與有效載荷(payload)。

通用形式範例:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "eventId": "evt_gg_103",
    "type": "job.progress",
    "timestamp": "2025-11-21T10:19:00Z",
    "jobId": "giftjob_42",
    "payload": {
      "percentage": 60,
      "stage": "比對評價",
      "etaSeconds": 15
    }
  }
}

在此結構中可區分:

  • eventId——事件唯一識別碼,用於在客戶端去重複;
  • type——事件的邏輯名稱(可重複/正規化 method);
  • timestamp——事件由伺服器產生的時間;
  • jobId 或其他 correlation-id——用來知道事件所屬;
  • payload——實際資料。每種事件各有其結構。

在真實系統中,你幾乎一定會想用 JSON Schema 或至少 TypeScript 型別來正式描述這些結構,以便伺服器與客戶端都能驗證訊息。有些團隊會使用受 CloudEvents 啟發的格式:裡面也有標準欄位如 idsourcetypetime 等。

但核心理念很簡單:事件應機器可讀且一致——不要出現「有時欄位叫 jobId、有時叫 job_id、有時又沒有」這種驚喜。

在後續範例裡,為了不讓程式過度冗長,我們會更常使用「扁平化」版本:把事件資料直接放在 params 內,不另外嵌 payload,而 type 也可能省略,若其角色已由 method 扮演。原則不變:每個事件都有穩定的中介資料(eventIdjobIdtimestamp)與可預期的有效載荷。

5. 事件的冪等性:為什麼以及如何做

現在來到本課最重要的詞——冪等性。

事件處理器的冪等性意味著:同一事件被處理一次或十次,系統的最終狀態都仍然正確。在帶有網路與重試的分散式系統中,這幾乎是生死攸關。

為什麼同一事件可能會來多次?

理由很多:從連線中斷與重新連線,到伺服器端的重送(為了保險再發一次通知)。使用串流協定時(例如伺服器主動把事件推進開放連線,如同 SSE——關於它會有單獨的傳輸課),這是經典情境:客戶端帶著 Last-Event-ID 重新連線,伺服器會補發漏掉的事件,其中一些會再次被客戶端看到。

如果你的處理器不具冪等性,就會出現怪象:

  • job.completed 事件導致重複發放獎勵或重複變更訂單狀態;
  • resource.updated 事件讓小工具每次都「新增」卡片,導致 UI 重複;
  • 重複的 job.progress 若讓進度條來回跳動,會嚇到使用者。

正確策略分兩層:伺服器端的事件產生,以及客戶端的事件處理。

伺服器端:穩定的 id 與狀態機

伺服器應:

  • 為每個邏輯事件產生唯一的 eventId
  • 保證同一個 jobId 的事件形成有效的狀態序列:你不應在 job.completed 之後再發 job.failed,或用兩個不同結果發出兩次 job.completed

也就是說,你實際上有一個任務狀態機,每個事件都是允許的轉換。

客戶端:去重複與「溫和」的更新

客戶端(小工具、代理或其他元件)應:

  • 至少在當前連線/工作階段期間,保存已處理的 eventId 集合;
  • 處理前先檢查:若 eventId 已看過,就忽略或只做不具副作用的 UI 重繪;
  • 收到改變任務狀態的事件(job.completedjob.failed)時,確保轉換是允許的:例如任務已標記為 completed,再次收到 job.completed 不應更動任何東西;而 failed 則更應該被視為不正確而忽略。

來自電商領域的經典例子:處理付款確認 webhook。同一個 order.paid 很可能來兩次;因此後端會保存 paymentId 與「已入帳」旗標。即使 webhook 再來一次,訂單狀態也不會改變。MCP 事件的設計也應採用相同思維。

6. 範例:為 GiftGenius 設計事件

把這些帶到我們的練習專案 GiftGenius。想像一個長任務場景:使用者上傳一個大型 CSV,包含員工清單與興趣,並要求「為所有人提供禮物點子」。此操作可能需要數十秒。

合理的事件模型可以如此描述:

  1. 使用者啟動工具 start_bulk_gift_analysis。工具回傳 jobId"bulk_2025_001"
  2. MCP 伺服器建立任務,並幾乎立刻發送 job.started,附簡短說明。
  3. 隨著執行進度,它會發送數個 job.progress 與階段內容:
    • 10% —「解析檔案並檢查格式」;
    • 40% —「擷取興趣與部門資訊」;
    • 70% —「依分類匹配禮物」;
    • 100% — 完成前的最後一步。
  4. 最後收到 job.completed,包含最終建議結果的資源連結。
  5. 如果事情不順利——就會以 job.failed 取代 completed,並附上錯誤碼與可能的修正建議。

口語上大致如此,但我們把兩個關鍵事件 job.progressjob.completed 固化成 JSON 架構。以下為簡化的「類 JSON Schema」:

{
  "job.progress": {
    "type": "object",
    "properties": {
      "eventId": { "type": "string" },
      "jobId": { "type": "string" },
      "timestamp": { "type": "string", "format": "date-time" },
      "percentage": { "type": "number", "minimum": 0, "maximum": 100 },
      "stage": { "type": "string" },
      "etaSeconds": { "type": "number" }
    },
    "required": ["eventId", "jobId", "timestamp", "percentage", "stage"]
  }
}
{
  "job.completed": {
    "type": "object",
    "properties": {
      "eventId": { "type": "string" },
      "jobId": { "type": "string" },
      "timestamp": { "type": "string", "format": "date-time" },
      "summary": { "type": "string" },
      "resultResourceId": { "type": "string" }
    },
    "required": ["eventId", "jobId", "timestamp", "resultResourceId"]
  }
}

你不一定要立刻實作完整的架構驗證,但在腦中保持這樣的結構很有幫助:它能避免欄位在不同格式之間「散落」並且避免遺漏重要中介資料。

7. 小實作:會發送 MCP 事件的伺服器

現在把理論與一小段 TypeScript 假想程式碼結合。我們不會動到實際的 MCP 函式庫(其一,它們仍在演進;其二,重點在模型),而是畫出結構骨架。

假設在我們的 MCP 伺服器中有個抽象函式 sendNotification,能把 JSON-RPC notification 發回 ChatGPT。假想介面:

// 發送 MCP 通知的工具函式
async function sendNotification(
  method: string,
  params: Record<string, unknown>
) {
  // 在這裡序列化 JSON 並透過現有的 MCP 連線發送
}

現在實作工具處理器 start_bulk_gift_analysis。它會註冊任務、回傳 jobId,並在背景「滴答作響」地發送進度。在真實世界這會是 worker 與佇列,但此處用計時器簡化。

type Job = {
  id: string;
  status: "pending" | "running" | "completed" | "failed";
};

const jobs = new Map<string, Job>();

export async function startBulkGiftAnalysisTool() {
  const jobId = `bulk_${Date.now()}`;
  jobs.set(jobId, { id: jobId, status: "pending" });

  // 先送出 job.started
  await sendNotification("notifications/job/started", {
    eventId: `evt_${jobId}_started`,
    jobId,
    timestamp: new Date().toISOString(),
    summary: "已啟動大型禮物清單分析"
  });

  simulateJob(jobId); // 在背景中 "啟動" 任務

  return { jobId };
}

任務模擬本體:

async function simulateJob(jobId: string) {
  jobs.set(jobId, { id: jobId, status: "running" });

  const stages = [
    { percent: 10, stage: "解析 CSV" },
    { percent: 40, stage: "分析興趣" },
    { percent: 70, stage: "挑選禮物" },
    { percent: 100, stage: "產生結果" }
  ];

  for (const s of stages) {
    await sendNotification("notifications/job/progress", {
      eventId: `evt_${jobId}_${s.percent}`,
      jobId,
      timestamp: new Date().toISOString(),
      percentage: s.percent,
      stage: s.stage
    });
    await new Promise(r => setTimeout(r, 1000));
  }

  jobs.set(jobId, { id: jobId, status: "completed" });

  await sendNotification("notifications/job/completed", {
    eventId: `evt_${jobId}_done`,
    jobId,
    timestamp: new Date().toISOString(),
    summary: "禮物分析已完成",
    resultResourceId: `resource:gifts:${jobId}`
  });
}

這段程式刻意簡單,但清楚展示了:

  • 使用事件序列 startedprogress* → completed
  • 每個事件都有唯一的 eventId
  • 所有事件都綁定同一個 jobId

未來當你加入真正的佇列與 worker,事件的結構仍大同小異——差別只在於哪裡呼叫 sendNotification

8. 客戶端:最簡單的冪等事件處理器

在客戶端(例如你的 Apps SDK 小工具)需要學會接收這些事件、把它們與當前任務關聯,並且不被重複事件搞混。

先不深入傳輸層(之後會談),假設有個函式 onMcpNotification,每當收到一個 notification,你的 MCP 客戶端層就會呼叫它。

加入最簡單的去重複:

const processedEvents = new Set<string>();

function handleNotification(method: string, params: any) {
  const eventId = params.eventId as string | undefined;
  if (!eventId) return; // 很有爭議,但拿來示範可以

  if (processedEvents.has(eventId)) {
    // 重複 — 忽略或溫和更新 UI
    return;
  }
  processedEvents.add(eventId);

  if (method === "notifications/job/progress") {
    updateJobProgress(params.jobId, params.percentage, params.stage);
  } else if (method === "notifications/job/completed") {
    markJobCompleted(params.jobId, params.resultResourceId);
  }
}

updateJobProgressmarkJobCompleted 的實作就是純 React/UI 程式:

function updateJobProgress(jobId: string, percent: number, stage: string) {
  // 例如放進 Zustand/Redux/React state
  console.log(`Job ${jobId}: ${percent}% — ${stage}`);
}

function markJobCompleted(jobId: string, resourceId: string) {
  console.log(`Job ${jobId} 已完成,資源:${resourceId}`);
}

這樣的處理器:

  • 即使事件來兩次也不會壞掉;
  • 不會產生副作用(例如「第二次又顯示了完成的對話框」);
  • 為更複雜的邏輯鋪路,例如驗證允許的狀態轉換(不允許在已 completed 後再來 failed)。

在實務程式碼中,你很可能會在重新連線 MCP 伺服器時清空 processedEvents,並且不僅保存 eventId,也保存每個 jobId 的當前狀態,以便在事件順序怪異時能更理性地行為。

接下來要理解的是,所有這些 MCP 事件如何穿越代理/小工具並變成具體的使用者體驗:進度條、階段、與最終結果。讓我們把事件與 run/workflow 及 UX 串起來。

9. 事件、run/workflow 與 UX 的串聯

雖然我們已經有完整的 workflow 與代理模組,現在你會看到全貌。我們已經引入了事件家族(job.*resource.*、系統事件);來看看它們如何穿過代理/小工具與 ChatGPT,並轉換為具體的使用者體驗。

典型的長任務場景如下:ChatGPT 呼叫 MCP 工具並取得 jobId;之後伺服器依此 jobId 推送進度、完成或錯誤事件;你的小工具或代理邏輯則據此更新 UI 並做出決策。

在時序圖上可以這樣表示:

sequenceDiagram
    participant User as 使用者
    participant GPT as ChatGPT(模型)
    participant App as GiftGenius MCP 伺服器
    participant Widget as GiftGenius 小工具

    User->>GPT: "為 2000 名員工挑選禮物"
    GPT->>App: tools.call start_bulk_gift_analysis
    App-->>GPT: response { jobId: "bulk_2025_001" }

    GPT->>Widget: ToolOutput { jobId }
    Widget->>Widget: 顯示進度條

    App-->>GPT: notification job.started
    App-->>GPT: notification job.progress (10%, 40%, 70%, 100%)
    App-->>GPT: notification job.completed { resultResourceId }

    GPT->>Widget: 將事件/資料轉交給小工具
    Widget->>User: 更新進度並顯示結果
    

實際的圖會更複雜一點,但關鍵觀念很簡單:MCP 事件是連接背景作業與使用者體驗的「神經系統」。

10. 使用 MCP 事件時的常見錯誤

錯誤 №1:「事件 = production 格式的日誌」。
有時開發者一開始只是把原本寫進 console.log 的東西丟進 MCP。結果事件裡沒有 eventId、沒有 jobId、沒有像樣的 timestamp,只有半詩意的訊息「我們快好了」。這讓系統變得脆弱:難以解析、無法去重複、UI 不知道該把訊息歸屬到哪個任務。最好一開始就把事件設計成正式合約:清晰的方法名、穩定的欄位集合、合乎邏輯的 payload。

錯誤 №2:缺乏冪等性與唯一的 eventId
很多人以為「事件只會來一次」。一週後就開始:客戶端重連時通知重複、使用者收到重複內容、商務後端重複發獎勵。沒有唯一的 eventId 與基本的客戶端去重複,早晚會踩到大雷。在分散式系統中,請以「at-least-once delivery」模型思考:重複是無可避免的。

錯誤 №3:把系統事件與商務事件混成一鍋。
例如同一個串流同時灑進 logging/messagejob.progressjob.completedresources/updated,卻沒有清楚的 type/method 界線。於是 UI 層開始寫出奇怪的 if (message.includes("完成")) 來判斷任務是否結束。最好清楚分離:系統通知(日誌、heartbeat)與商務事件(job.*resource.*)各自有嚴格描述的結構。

錯誤 №4:任務狀態轉換不一致。
有時伺服器在同一事件串流中先發 job.completed,接著突然又是 job.progress,然後 job.failed。若沒有明確的狀態機與發送檢查,就會發生這種情況。客戶端完全無法理解實際狀態。正確做法是描述有限狀態機,並禁止違反它的事件:例如在 completed 之後,最多只發額外資訊事件,而不是把任務送回 running

錯誤 №5:過度綁定當前規範版本的 MCP 方法名。
MCP 規格仍在演進。若你把一切都綁在當前的系統方法名上,而不使用自己的命名空間,一旦協定更動,你就得改半套系統。更好的方式是把事件視為 MCP 之上的迷你規範:可以依現有方法(如 notifications/progressresources/updated)為基礎,但把商務事件(notifications/job/*)設計在自己的命名空間中,讓它們相對獨立。

錯誤 №6:事件與 UX 脫節。
有時團隊在後端做了漂亮的事件模型,卻沒有把它接到小工具上:job.progress 只在日誌存在,UI 卻顯示 40 秒的孤單轉圈。使用者在這種情境下不會信任 MCP 或 AI。設計事件時,永遠思考你要得到的具體 UI 效果:進度條、階段、部分結果。MCP 事件不是為了協定而存在,而是為了可理解的應用行為。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION