ข้ามไปยังเนื้อหา

Progress, Cancellation, and Timeouts

tool call ไม่ได้เสร็จทันทีทุกอัน import catalog, รัน test suite, render รายงาน — พวกนี้ใช้เวลาหลายวินาทีถึงหลายนาที และระหว่างนั้นมีสองอย่างที่ผิดพลาดได้: client ยอมแพ้ (timeout) หรือผู้ใช้เปลี่ยนใจแต่ไม่มีใครบอก server progress notification แก้อย่างแรก cancellation แก้อย่างหลัง

ทั้งสองอย่างยังอยู่ ภายใน request เดียว ถ้างานจะอยู่นานกว่า request ที่สมเหตุสมผล — หลายนาทีถึงหลายชั่วโมง หรือต้องรอดจากการหลุด connection — ให้ไปใช้ Tasks extension แทน

progress เริ่มจากฝั่ง client: client ที่ อยาก ได้ update จะใส่ progressToken ไว้ใน _meta ของ request token เป็น string หรือ integer ที่ client เลือกเอง และต้องไม่ซ้ำกันในบรรดา request ที่ยัง active ของ client นั้น

{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "import_catalog",
"arguments": { "urls": ["https://a.example/1.csv", "https://a.example/2.csv"] },
"_meta": {
"progressToken": "imp-7",
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

ไม่มี token ก็ไม่มี progress — server ต้องส่ง progress เฉพาะกับ token ที่ได้รับมาจริงบน request ที่ยังค้างอยู่

{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "imp-7",
"progress": 1,
"total": 2,
"message": "Imported https://a.example/1.csv"
}
}

กฎที่สำคัญ:

  • progress ต้องเพิ่มขึ้น ทุก notification แม้จะไม่รู้ total
  • total และ message เป็น optional ทั้งสองค่าเป็นทศนิยมได้
  • server จะ ไม่ส่งเลย หรือส่งกี่ครั้งก็ได้ แต่ ควร rate-limit ไม่ใช่ถล่มส่ง
  • notification ต้องหยุด ทันทีที่ request เสร็จ
sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: tools/call พร้อม progressToken
  S-->>C: notifications/progress 1 of 3
  S-->>C: notifications/progress 2 of 3
  S-->>C: notifications/progress 3 of 3
  S-->>C: result
  Note over C,S: หลัง result แล้วไม่มี progress อีก
progress วิ่งไปกับ request ที่มันสังกัด
  • Streamable HTTP: server ตอบ POST ด้วย SSE stream ที่ผูกกับ request นั้น progress event มาบน stream นี้ ตามด้วย JSON-RPC response สุดท้ายซึ่งปิด stream progress ไม่มีทางโผล่บน stream ของ subscriptions/listen
  • stdio: ทุกอย่างใช้ stdout channel เดียวร่วมกัน client จับคู่ notification กับ request ด้วย progressToken
server.registerTool(
'import_catalog',
{ description: 'Import catalog files', inputSchema: z.object({ urls: z.array(z.string()) }) },
async ({ urls }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken;
for (let i = 0; i < urls.length; i++) {
await importOne(urls[i]);
if (progressToken !== undefined) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken, progress: i + 1, total: urls.length, message: `Imported ${urls[i]}` },
});
}
}
return { content: [{ type: 'text', text: `Imported ${urls.length} files` }] };
}
);

client ทิ้ง request ที่ไม่สนใจแล้วได้ วิธี ขึ้นกับ transport เพราะ transport สองแบบมองว่า “request หนึ่งอัน” ในทางกายภาพคืออะไรไม่เหมือนกัน

Transportclient cancel อย่างไรทำไม
stdioส่ง notifications/cancelled พร้อม requestId ของ request นั้น (และ reason ถ้าต้องการ)channel เดียวใช้ร่วมกัน — ไม่มี stream ต่อ request ให้ปิด
Streamable HTTPปิด SSE response stream ของ request นั้นแต่ละ request มี stream ของตัวเอง การตัด connection จึงชัดเจนในตัว ไม่ต้องส่ง notifications/cancelled
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "requestId": 7, "reason": "user pressed stop" }
}

ไม่ว่า transport ไหน server ควร หยุดงานเร็วที่สุดเท่าที่ทำได้ และ ต้องไม่ ส่ง message ใด ๆ ของ request นั้นอีก — ไม่มี progress ที่มาช้า ไม่มี result ส่วน client ก็ควรเมินอะไรก็ตามที่มาถึงหลังจาก cancel ไปแล้ว race มันเกิดขึ้นได้

กฎขอบ ๆ ที่ควรรู้:

  • stream ของ subscriptions/listen ถูก cancel แบบเดียวกัน — ปิด stream บน HTTP หรือส่ง notifications/cancelled ด้วย id ของ listen request บน stdio
  • cancellation เป็น best-effort และต้องร่วมมือกัน tool ที่ทำ side effect ที่ย้อนกลับไม่ได้ไปครึ่งทางอาจทำจนจบ ออกแบบ tool ให้สิ่งที่เกิดขึ้นไปแล้วมองเห็นได้ (return handle, ทำให้การเขียน idempotent)
  • หมายเหตุยุคเก่า: บน stdio notifications/cancelled เป็น notification จาก client ไป server ตัวเดียว ที่เหลืออยู่ใน core protocol 2026-07-28

MCP ไม่ได้บังคับ timeout ปล่อยให้ client กำหนดนโยบายเอง และนั่นคือการตัดสินใจออกแบบที่คุณจะต้องทำใน host ทุกตัวที่สร้าง มีสองตัวเลือกที่สมเหตุสมผล:

  1. deadline คงที่ — ง่ายที่สุด ทุก request ได้ เช่น 60 วินาที ถ้าหมดก็ cancel คาดเดาได้ แต่ฆ่า tool ที่ใช้เวลานานโดยชอบธรรม
  2. deadline ที่ reset ตาม progress — timer เริ่มนับใหม่ทุกครั้งที่ notifications/progress มาถึง โดยมีเพดานสูงสุดเพื่อไม่ให้ server ที่พูดมากแต่ค้างรันไปตลอดกาล นี่คือ pattern ที่ host ส่วนใหญ่ลงเอยด้วย: server ที่เงียบถือว่าตายแล้ว server ที่ยังพูดอยู่ได้รับความอดทน แต่มีลิมิต

server ควรสมมติว่าตัวเลือก 2 มีอยู่ และส่ง progress กับอะไรก็ตามที่ช้ากว่าสองสามวินาที — มันคือความต่างระหว่าง “กำลัง import 4,000 แถวอยู่” กับ “request timed out”

เมื่อ timeout ทำงาน client ควร cancel จริง ๆ (ปิด stream หรือส่ง notifications/cancelled) ไม่ใช่แค่หยุดฟัง เพื่อให้ server คืน resource ได้

  • progress มีไว้ให้ผู้ใช้ ไม่ใช่ model ใส่ข้อความที่คนอ่านรู้เรื่องใน message อย่าเข้ารหัสข้อมูลที่ LLM ควรเอาไปทำอะไรต่อ
  • ห้าม block เพราะ progress การส่ง notification ต้องไม่ทำให้งานชะงัก มองมันเป็น fire-and-forget
  • tool ที่ยาวแต่ไม่มี progress คือ bug ใน production ถ้างานรายงาน progress ไม่ได้ มันน่าจะเหมาะกับ Tasks extension มากกว่า
server ส่ง notifications/progress ได้เมื่อไร?
client แบบ Streamable HTTP cancel tools/call ที่กำลังรันอยู่อย่างไร?
ข้อความไหนเกี่ยวกับค่า progress ที่ถูกต้อง?
หลังได้รับ cancellation server ต้องไม่ทำอะไร?