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

Change Notifications, Subscriptions, and Caching

ชุด tool โตขึ้นเมื่อ deploy, resource อัปเดตเมื่อไฟล์ถูกแก้, prompt ถูกเพิ่มโดย admin client ที่รัน tools/list ซ้ำก่อนทุกเทิร์นเสีย round trip เปล่า ๆ; client ที่ไม่เคยรันซ้ำก็ใช้ข้อมูลเก่า MCP แก้ปัญหานี้ด้วยกลไกสองอย่างที่ทำงานร่วมกัน:

  1. notification แบบ opt-in ผ่าน request subscriptions/listen ที่เปิดค้างไว้ เพื่อให้ server บอกว่า “มีอะไรเปลี่ยน” ได้ทันทีที่มันเกิดขึ้น
  2. caching hint (ttlMs, cacheScope) บนผลลัพธ์ list และ read ทุกตัว เพื่อให้ client รู้ว่าคำตอบที่ cache ไว้ใช้ได้นานแค่ไหน

ทั้งสองอย่างไม่ได้การันตีการส่งถึง notification เป็นแบบ best-effort — การ reconnect อาจทำหล่นสักตัว — ดังนั้น client ยังต้อง refresh เมื่อ TTL หมด สองอย่างรวมกันให้คุณสภาพ ปกติทันที และถูกต้องในที่สุดเสมอ

ใน revision 2026-07-28 ไม่มี GET stream แยกและไม่มี method resources/subscribe client ส่ง request เดียวและระบุชัดว่าต้องการ notification type ไหนบ้าง:

{
"jsonrpc": "2.0",
"id": 7,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"promptsListChanged": true,
"resourcesListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
Field ของ filterTypeส่งมอบ
toolsListChangedbooleannotifications/tools/list_changed
promptsListChangedbooleannotifications/prompts/list_changed
resourcesListChangedbooleannotifications/resources/list_changed
resourceSubscriptionsstring[]notifications/resources/updated สำหรับแต่ละ URI ที่ระบุ

ทุก field เลือกใส่ได้; ไม่ใส่หมายถึง “ไม่ต้องส่งอันนั้น” server ต้องไม่ ส่ง notification type ที่ client ไม่ได้ขอ

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: subscriptions/listen (id 7, filter)
  S-->>C: notifications/subscriptions/acknowledged (subscriptionId 7)
  Note over C,S: stream เปิดค้างไว้
  S-->>C: notifications/tools/list_changed (subscriptionId 7)
  C->>S: tools/list
  S-->>C: tools[] ชุดใหม่
  S-->>C: notifications/resources/updated uri=file:///project/config.json
  C->>S: resources/read
  S-->>C: result สำหรับ id 7 (ปิดอย่างสุภาพ)
อายุของ subscription หนึ่งตัว

message แรก ของ server สำหรับ subscription หนึ่งคือ notifications/subscriptions/acknowledged เสมอ มันถือ subscription ID — ซึ่งก็คือ id ของ JSON-RPC ของ request subscriptions/listen นั่นเอง — และสะท้อน ส่วนย่อย ของ filter ที่ server ตกลงจะให้กลับมา:

{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": { "io.modelcontextprotocol/subscriptionId": 7 },
"notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///project/config.json"] }
}
}

ตรงนี้ server ตัด promptsListChanged และ resourcesListChanged ทิ้ง — อาจเพราะมันไม่ได้ประกาศ capability listChanged พวกนั้น client ควรเทียบ filter ที่ได้รับการยืนยันกับที่ขอไป แล้วถอยไป poll สำหรับสิ่งที่ขาด

notification ทุกตัวบน stream มี io.modelcontextprotocol/subscriptionId ใน _meta:

{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": { "_meta": { "io.modelcontextprotocol/subscriptionId": 7 }, "uri": "file:///project/config.json" }
}

บน Streamable HTTP subscriptions/listen แต่ละตัวได้ SSE response stream ของตัวเอง ป้ายนี้จึงเป็นแค่ความสะดวก แต่บน stdio ทุก message ใช้ช่องทางเดียวกัน และ client ต้อง ใช้ป้ายนี้ในการ demultiplex — คุณอาจมี subscription หนึ่งสำหรับการเปลี่ยนแปลงของ tool และอีกตัวสำหรับ resource ที่มีชีวิตอยู่พร้อมกัน

เฉพาะ notification เรื่อง การเปลี่ยนแปลง เท่านั้นที่ไหลบน listen stream notification ที่ผูกกับ request อย่าง notifications/progress และ notifications/message เดินทางบน response stream ของ request ที่มันสังกัด ไม่ใช่ตรงนี้

  • client ยกเลิก: ปิด SSE stream (HTTP) หรือส่ง notifications/cancelled โดยระบุ ID ของ listen request (stdio)
  • server ปิดอย่างสุภาพ (shutdown, redeploy): ควรส่ง result ปกติสำหรับ listen request ติดป้ายด้วย subscription ID แล้วค่อยปิด result หมายถึง “ปิดเรียบร้อย”; transport หลุดโดยไม่มี result หมายถึง “ไม่คาดคิด — ควรพิจารณา reconnect”
{ "jsonrpc": "2.0", "id": 7, "result": { "resultType": "complete", "_meta": { "io.modelcontextprotocol/subscriptionId": 7 } } }
  • transport ตาย: HTTP timeout, TCP reset, process ของ stdio จบ เพราะ protocol เป็น stateless server จึงไม่เก็บ state ของ subscription ไว้; บน stdio หลัง restart client ต้อง ส่ง subscriptions/listen อีกครั้ง

SDK จัดการงานเบื้องหลังของ stream ให้; หน้าที่ของคุณคือส่งสัญญาณว่ามีอะไรเปลี่ยน

const server = new McpServer(
{ name: 'board', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } } },
);
// Registering a tool after the server is live triggers
// notifications/tools/list_changed for subscribed clients.
function enableReports() {
server.registerTool(
'sprint_report',
{ description: 'Summarize completed tasks' },
async () => ({ content: [{ type: 'text', text: report() }] }),
);
}

McpServer ติดตามการลงทะเบียน; การเพิ่ม, ลบ, เปิด หรือปิด tool หลัง startup ทำให้ SDK ส่ง notification แบบ list-changed ไปยัง client ที่ฟังอยู่ ส่วนการอัปเดต resource ส่งสัญญาณผ่าน notification API ของ server สำหรับ URI นั้น ๆ

อย่าลืมกฎจากหน้าแรกของโมดูล: การเปลี่ยนแปลงต้องเป็น ระดับ global (หรือตาม authorization) ไม่ใช่ต่อ connection enable_reports ข้างบนเพิ่ม tool ให้ทุกคน — แบบนั้นทำได้ แต่การเพิ่มให้เฉพาะผู้เรียกที่กดมันทำไม่ได้

ผลลัพธ์ของ tools/list, prompts/list, resources/list, resources/templates/list, resources/read และ server/discover ทุกตัวมี field บังคับสองตัว:

Fieldค่าความหมาย
ttlMsinteger เป็น msคำใบ้เรื่องความสด: client ใช้ผลลัพธ์นี้ซ้ำได้นานเท่านี้โดยไม่ต้องถามใหม่
cacheScope"public" หรือ "private"public: intermediary ที่ใช้ร่วมกัน (gateway, corporate proxy) cache แล้วเสิร์ฟให้ผู้เรียกรายอื่นได้ private: เฉพาะผู้เรียกรายนี้ cache ได้ — ใช้กับอะไรก็ตามที่ขึ้นกับ authorization ของผู้เรียก

แนวทางสำหรับ server:

  • tool list แบบ static: TTL ยาว (หลายนาทีถึงหนึ่งชั่วโมง), public
  • tool list ที่กรองตาม scope: private เพราะผู้เรียกรายอื่นต้องไม่ได้รับมันจาก cache
  • resources/read ของเอกสารเฉพาะ user: TTL สั้น, private

แนวทางสำหรับ client: เคารพ TTL แต่ ให้ถือว่า list ที่ cache ไว้เก่าทันทีที่ notification list_changed ที่ตรงกันมาถึง แม้ TTL จะยังไม่หมด notification ชนะ TTL

เพราะ tool list ของ model มักอยู่ใน prompt prefix ของ LLM host จึงจับคู่สิ่งนี้กับลำดับที่แน่นอน (ดู Tools) เพื่อให้ cache hit ฝั่ง MCP ยังเป็น cache hit ฝั่ง LLM ด้วย

method */list ทุกตัวรับ cursor แบบเลือกใส่ และอาจคืน nextCursor:

{ "method": "tools/list", "params": { "cursor": "eyJwYWdlIjoyfQ" } }
  • cursor เป็น string ทึบ client ต้องไม่ parse หรือประกอบมันขึ้นเอง; server จะเข้ารหัสเลขหน้า, offset หรือ keyset อย่างไรก็ได้
  • ไม่มี nextCursor หมายความว่านี่คือหน้าสุดท้าย
  • ขนาดหน้าเป็นเรื่องที่ server เลือก ทำให้หน้าเล็กพอที่ ttlMs ยังมีความหมายสำหรับแต่ละหน้า

client ที่ต้องการ “tool ทั้งหมด” จะวนจนกว่า nextCursor จะหายไป แล้ว cache list ที่ประกอบได้ด้วย ttlMs ที่ น้อยที่สุด ที่เห็น

ใน protocol version 2025-11-25 และก่อนหน้า change notification มาถึงบน HTTP GET stream แยกต่างหาก (หรือถูกส่งมาเองบน stdio) โดยไม่ต้อง opt-in และการอัปเดต resource ใช้ request resources/subscribe และ resources/unsubscribe อย่างชัดเจน ttlMs และ cacheScope ยังไม่มี เมื่อคุยกับ server ยุคเก่า อย่าคาดหวัง filter semantic ข้างบนเลย; ดู Backward Compatibility

ค่าของ io.modelcontextprotocol/subscriptionId บน notification จาก subscription คืออะไร?
acknowledgment ไม่มี promptsListChanged ทั้งที่ client ขอไป หมายความว่าอย่างไร?
tools/list ที่ cache ไว้ยังเหลือ ttlMs อีก 4 นาที แต่ notifications/tools/list_changed มาถึง client ควรทำอย่างไร?
server ควรใช้ cacheScope แบบไหนกับ tool list ที่ขึ้นกับ OAuth scope ของผู้เรียก?