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/progress、resources/updated、logging/message |
MCP 事件正位於第三列:notifications。其特徵:
- 最上層沒有 id——因此不會有 result 或 error 回應;
- 發起方不等待 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"
}
}
這裡有幾個關鍵點:
- 在 method 欄位中,我們編碼事件類型與其「命名空間」。MCP 已定義了若干標準的 notifications/... 方法(用於日誌、進度與資源變更),但你可以且應該新增自己的商務事件方法,例如 notifications/job/progress 或 notifications/job/completed。
- 所有商務資料位於 params。我們也會在那裡放置任務識別碼(jobId)、事件唯一 id(eventId)、時間(timestamp)、人類可讀訊息等。
- 最上層缺少 id 欄位——這就是 notification。協定不提供回應。如果伺服器想知道「對方是否理解」,它可以再發送一個事件,或等待客戶端的反應行為(例如新請求)。但在 JSON-RPC 意義上並沒有 ACK。
在心智模型層面可以這樣想:呼叫工具 tools/call 是「一封你期待回覆的信」,而事件是來自 Slack 機器人的通知:「背景任務 #123 已完成」。
3. 事件的分類學:有哪些通知
如果只是放任「隨便把 JSON 當 notifications 發」,兩週後系統會變成垃圾堆:事件命名各不相同、欄位漂移、UI 不知道該怎麼處理。因此,約定一個小而清晰的分類很有幫助。
下面是一種實用的分類方式,能與 MCP 規範及 ChatGPT Apps 的實際案例很好地對齊。
任務生命週期事件(Job Lifecycle)
這類事件反映任務狀態的關鍵轉換。通常任務有一個狀態機(state machine),例如 pending → running →(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/updated、resources/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 啟發的格式:裡面也有標準欄位如 id、source、type、time 等。
但核心理念很簡單:事件應機器可讀且一致——不要出現「有時欄位叫 jobId、有時叫 job_id、有時又沒有」這種驚喜。
在後續範例裡,為了不讓程式過度冗長,我們會更常使用「扁平化」版本:把事件資料直接放在 params 內,不另外嵌 payload,而 type 也可能省略,若其角色已由 method 扮演。原則不變:每個事件都有穩定的中介資料(eventId、jobId、timestamp)與可預期的有效載荷。
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.completed、job.failed)時,確保轉換是允許的:例如任務已標記為 completed,再次收到 job.completed 不應更動任何東西;而 failed 則更應該被視為不正確而忽略。
來自電商領域的經典例子:處理付款確認 webhook。同一個 order.paid 很可能來兩次;因此後端會保存 paymentId 與「已入帳」旗標。即使 webhook 再來一次,訂單狀態也不會改變。MCP 事件的設計也應採用相同思維。
6. 範例:為 GiftGenius 設計事件
把這些帶到我們的練習專案 GiftGenius。想像一個長任務場景:使用者上傳一個大型 CSV,包含員工清單與興趣,並要求「為所有人提供禮物點子」。此操作可能需要數十秒。
合理的事件模型可以如此描述:
- 使用者啟動工具 start_bulk_gift_analysis。工具回傳 jobId:"bulk_2025_001"。
- MCP 伺服器建立任務,並幾乎立刻發送 job.started,附簡短說明。
- 隨著執行進度,它會發送數個 job.progress 與階段內容:
- 10% —「解析檔案並檢查格式」;
- 40% —「擷取興趣與部門資訊」;
- 70% —「依分類匹配禮物」;
- 100% — 完成前的最後一步。
- 最後收到 job.completed,包含最終建議結果的資源連結。
- 如果事情不順利——就會以 job.failed 取代 completed,並附上錯誤碼與可能的修正建議。
口語上大致如此,但我們把兩個關鍵事件 job.progress 與 job.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}`
});
}
這段程式刻意簡單,但清楚展示了:
- 使用事件序列 started → progress* → 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);
}
}
updateJobProgress 與 markJobCompleted 的實作就是純 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/message、job.progress、job.completed、resources/updated,卻沒有清楚的 type/method 界線。於是 UI 層開始寫出奇怪的 if (message.includes("完成")) 來判斷任務是否結束。最好清楚分離:系統通知(日誌、heartbeat)與商務事件(job.*、resource.*)各自有嚴格描述的結構。
錯誤 №4:任務狀態轉換不一致。
有時伺服器在同一事件串流中先發 job.completed,接著突然又是 job.progress,然後 job.failed。若沒有明確的狀態機與發送檢查,就會發生這種情況。客戶端完全無法理解實際狀態。正確做法是描述有限狀態機,並禁止違反它的事件:例如在 completed 之後,最多只發額外資訊事件,而不是把任務送回 running。
錯誤 №5:過度綁定當前規範版本的 MCP 方法名。
MCP 規格仍在演進。若你把一切都綁在當前的系統方法名上,而不使用自己的命名空間,一旦協定更動,你就得改半套系統。更好的方式是把事件視為 MCP 之上的迷你規範:可以依現有方法(如 notifications/progress、resources/updated)為基礎,但把商務事件(notifications/job/*)設計在自己的命名空間中,讓它們相對獨立。
錯誤 №6:事件與 UX 脫節。
有時團隊在後端做了漂亮的事件模型,卻沒有把它接到小工具上:job.progress 只在日誌存在,UI 卻顯示 40 秒的孤單轉圈。使用者在這種情境下不會信任 MCP 或 AI。設計事件時,永遠思考你要得到的具體 UI 效果:進度條、階段、部分結果。MCP 事件不是為了協定而存在,而是為了可理解的應用行為。
GO TO FULL VERSION