你讓 Claude Code 在背景整理報表,正準備闔上筆電,它卻需要補問:「報表要叫什麼名字?」MCP Tasks 教學要解決的,就是這個容易被忽略的交接:工作交出去以後,誰收問題、誰負責停止,取消後又該相信哪份結果?
本文先用白話帶零基礎讀者看懂,再給已有 Python 3 環境的讀者一個可複製的假報表 Server。你會從收據、補問到取消跑完一條流程,不必交出 API 金鑰或連接真實帳號。前半篇可以直接讀;程式區可留到動手時再展開。
先說結論:工作收據,不是停工保證
Tasks=可回查的工作收據;Elicitation=需要你回答的補件單;Cancel=停工要求。收據讓你找到工作,補件單讓工作等到必要資訊,停工要求則必須由真正執行工作的伺服器處理。三件事接起來,才是可管理的背景任務。
截至 2026 年 10 月 26 日查閱,Claude Code 官方文件列出 CLI v2.1.304 以上的 MCP Tasks public beta。本文固定 2026-07-28 協定與 Tasks extension;是否真的啟用,仍要看連線與工作階段條件,不能只看版本號。
MCP Tasks 教學第一步:先分清五個零件
- 工具呼叫
tools/call:像到櫃台下單。一般回覆直接交成果;符合條件時,Server 可先交 Task handle,也就是工作識別資料。 - 狀態查詢
tasks/get:拿收據問進度。working是處理中;input_required是等補件;completed、failed、cancelled是終態。 - 補問
inputRequests:Server 把問題放進具名的格子,例如title-1。裡面可以是一份elicitation/create表單。 - 答覆
tasks/update:Client 用相同格子名稱回傳inputResponses,讓答案對到正確問題。 - 取消
tasks/cancel:Client 表達停止意圖。Server 的確認回覆與工作實際停止,應分開記錄。
這套流程由 Tasks extension 正式版本文件定義。它與舊版 2025-11-25 的任務格式有差異:本文使用 resultType、ttlMs、pollIntervalMs,從 tasks/get 取得結果或待回答問題。請把整組版本一起固定,避免拿舊欄位套進新版範例。

第二步:能力要逐次核對,才能回傳 Task handle
想像櫃台要先確認你有可接收通知的地址,才交出稍後領件的收據。新版 MCP 也有相同的能力宣告:每次請求的 params._meta 都可以帶 Client 能力;Server 不能把前一次的支援宣告當作這次一定支援。
{
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
},
"elicitation": {
"form": {}
}
}
}
}
Server 透過 server/discover公開支援版本與 extension,再在每次 tools/call 檢查對方能力。本例遇到缺少 Tasks 或 form 能力時,回傳 -32021 與所需能力,既不建立工作,也不送出補問。每次 tasks/get、tasks/update、tasks/cancel 也會重新檢查 Tasks 能力。完整服務可另設計不需這些能力的同步工具路徑。
正式連線還要區分「協定有能力」與「產品有啟用」。Claude Code 文件另列新版協定連線、功能旗標、主對話呼叫與工作階段類型等條件;若實際請求沒有 Tasks extension,就走一般工具回覆。不要替 Client 猜測或自行補上能力。
第三步:建立不碰帳號的 Mock 報表 Server
先新建空白練習資料夾,把下面程式存成 mock.py。它只讀標準輸入的一行 JSON、回傳一行 JSON,並把假任務存進指定的 SQLite 檔。它不產生真報表、不寄信,也不呼叫外部 API;最後成果只是一行 FAKE REPORT 文字。
範例把「準備報表」縮成第一次查詢才提出標題問題,把「生成報表」縮成接受後立即完成,目的是讓狀態轉移容易觀察。這是一個單程序、單使用者的 stdio 練習器;未加入完整 JSON Schema 驗證、HTTP 傳輸與多人授權。要擴成服務,接著使用 官方 TypeScript Tasks 套件指南處理對應協定整合,並在每個 task 請求核對呼叫者權限。
展開可執行的 mock.py
import json, sqlite3, sys, time, uuid
from datetime import datetime, timezone
VERSION = '2026-07-28'
EXT = 'io.modelcontextprotocol/tasks'
META = 'io.modelcontextprotocol/'
db = sqlite3.connect(sys.argv[1] if len(sys.argv) > 1 else 'mock.db')
db.execute('CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, body TEXT)')
TERMINAL = {'completed', 'failed', 'cancelled'}
class CapabilityError(Exception):
def __init__(self, required):
self.required = required
def require_form(caps):
elicit = caps.get('elicitation')
if not isinstance(elicit, dict) or (elicit and 'form' not in elicit):
raise CapabilityError({'elicitation':{'form':{}}})
def stamp():
return datetime.now(timezone.utc).isoformat()
def save(t):
db.execute('INSERT OR REPLACE INTO jobs VALUES (?,?)',
(t['taskId'], json.dumps(t)))
db.commit()
def status(t, value):
t['status'], t['lastUpdatedAt'] = value, stamp()
if value != 'input_required':
t.pop('inputRequests', None)
def load(key):
row = db.execute('SELECT body FROM jobs WHERE id=?', (key,)).fetchone()
if not row:
raise ValueError('Unknown task')
t = json.loads(row[0])
if t['status'] not in TERMINAL and time.time() >= t['_deadline']:
status(t, 'cancelled')
t['statusMessage'] = 'Demo deadline reached; no report generated'
save(t)
return t
def public(t, kind='complete'):
return dict(resultType=kind, **{k:v for k,v in t.items() if not k.startswith('_')})
def dispatch(method, p):
meta = p.get('_meta', {})
if meta.get(META+'protocolVersion') != VERSION:
raise ValueError('Use protocol 2026-07-28')
caps = meta.get(META+'clientCapabilities')
if not isinstance(caps, dict):
raise ValueError('clientCapabilities must be an object')
if method.startswith('tasks/') and EXT not in caps.get('extensions', {}):
raise CapabilityError({'extensions':{EXT:{}}})
if method == 'server/discover':
return {'resultType':'complete', 'supportedVersions':[VERSION],
'capabilities':{'tools':{}, 'extensions':{EXT:{}}}}
if method == 'tools/list':
return {'resultType':'complete', 'tools':[{
'name':'mock_report', 'description':'Fake report; asks for a title',
'inputSchema':{'type':'object','properties':{},'additionalProperties':False}}]}
if method == 'tools/call':
if p.get('name') != 'mock_report':
raise ValueError('Unknown tool')
if EXT not in caps.get('extensions', {}):
raise CapabilityError({'extensions':{EXT:{}}})
require_form(caps)
now = stamp()
t = dict(taskId=uuid.uuid4().hex, status='working', createdAt=now,
lastUpdatedAt=now, ttlMs=None, pollIntervalMs=500,
_deadline=time.time()+30)
save(t)
return public(t, 'task')
if method not in {'tasks/get','tasks/update','tasks/cancel'}:
raise ValueError('Unknown method')
t = load(p['taskId'])
if method == 'tasks/get':
if t['status'] in {'working', 'input_required'}:
require_form(caps)
if t['status'] == 'working':
status(t, 'input_required')
t['inputRequests'] = {'title-1':{'method':'elicitation/create','params':{
'mode':'form','message':'Choose a fake report title',
'requestedSchema':{'type':'object','properties':{
'title':{'type':'string','maxLength':40}},'required':['title']}}}}
save(t)
return public(t)
if method == 'tasks/cancel':
if t['status'] not in TERMINAL:
status(t, 'cancelled')
t['statusMessage'] = 'Cancelled; no report generated'
save(t)
return {'resultType':'complete'}
answer = p.get('inputResponses', {}).get('title-1')
if t['status'] == 'input_required' and answer is not None:
# InputResponses values are direct elicitation results.
value = answer
if value.get('action') != 'accept':
status(t, 'cancelled')
else:
title = value.get('content', {}).get('title')
if not isinstance(title, str) or not 1 <= len(title) <= 40:
raise ValueError('Title must contain 1..40 characters')
status(t, 'completed')
t['result'] = {'resultType':'complete', 'content':[
{'type':'text','text':'FAKE REPORT: '+title}]}
save(t)
return {'resultType':'complete'}
for line in sys.stdin:
try:
req = json.loads(line)
if 'id' not in req:
continue
reply = {'jsonrpc':'2.0','id':req['id']}
try:
reply['result'] = dispatch(req['method'], req.get('params', {}))
except CapabilityError as e:
reply['error'] = {'code':-32021,'message':'Missing required client capability',
'data':{'requiredCapabilities':e.required}}
except (ValueError, KeyError, TypeError) as e:
reply['error'] = {'code':-32602,'message':str(e)}
print(json.dumps(reply), flush=True)
except json.JSONDecodeError:
print(json.dumps({'jsonrpc':'2.0','id':None,'error':{
'code':-32700,'message':'Parse error'}}), flush=True)
程式先提交 SQLite,再交回 handle,讓下一次查詢找得到同一個 task ID。ttlMs=None 只表示這個示範沒有設定紀錄保留期限;另設的 _deadline 才是本例三十秒的工作截止。兩者用途不同,不應共用一個欄位。
本例在每次存取任務時檢查截止時間,因此閒置期間沒有背景清理器。由於本例沒有真正持續執行的外部工作,這足以守住「過期後不再接受答案產出成果」;正式報表 worker 必須另有獨立計時與停止機制,並清掉暫存檔,不能等下一位 Client 來查才停。
第四步:跑一次訊息重播,看完整生命週期
把下段存成同資料夾的 replay.py,執行 python3 replay.py。它會啟動 Mock、送出請求、核對回覆,等待一次三十秒截止,再留下 trace.json。執行前確認資料夾內沒有你要保留的同名練習資料庫或 trace 檔。
展開訊息重播 replay.py
import json, subprocess, sys, time
from pathlib import Path
root = Path(__file__).resolve().parent
p = subprocess.Popen([sys.executable, str(root/'mock.py'), str(root/'verify.db')],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
meta = {'io.modelcontextprotocol/protocolVersion':'2026-07-28',
'io.modelcontextprotocol/clientCapabilities':{
'extensions':{'io.modelcontextprotocol/tasks':{}}, 'elicitation':{'form':{}}}}
trace=[]
def call(method, expect_error=False, **params):
params['_meta']=meta
req={'jsonrpc':'2.0','id':len(trace)+1,'method':method,'params':params}
p.stdin.write(json.dumps(req)+'\n');p.stdin.flush()
reply=json.loads(p.stdout.readline());trace.append({'request':req,'response':reply})
if expect_error:
assert reply.get('error',{}).get('code') == -32021, reply
return reply['error']
assert 'error' not in reply, reply
return reply['result']
def job():
t=call('tools/call',name='mock_report',arguments={});assert t['status']=='working'
t=call('tasks/get',taskId=t['taskId']);assert t['status']=='input_required'
return t['taskId']
assert call('server/discover')['supportedVersions']==['2026-07-28']
a=job();call('tasks/update',taskId=a,inputResponses={'title-1':{'action':'accept','content':{'title':'Alpha'}}})
assert call('tasks/get',taskId=a)['status']=='completed'
# A late cancellation acknowledgement must not rewrite a terminal state.
call('tasks/cancel',taskId=a);assert call('tasks/get',taskId=a)['status']=='completed'
b=job();call('tasks/cancel',taskId=b)
call('tasks/update',taskId=b,inputResponses={'title-1':{'action':'accept','content':{'title':'Late'}}})
assert call('tasks/get',taskId=b)['status']=='cancelled'
c=job();call('tasks/update',taskId=c,inputResponses={'title-1':{'action':'decline'}})
assert call('tasks/get',taskId=c)['status']=='cancelled'
d=job();p.stdin.close();p.wait()
p = subprocess.Popen([sys.executable,str(root/'mock.py'),str(root/'verify.db')],stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True)
assert call('tasks/get',taskId=d)['status']=='input_required'
time.sleep(31)
assert call('tasks/get',taskId=d)['status']=='cancelled'
# Check each request's capability; earlier declaration must not carry over.
old=meta;meta={'io.modelcontextprotocol/protocolVersion':'2026-07-28','io.modelcontextprotocol/clientCapabilities':{}}
call('tools/call',expect_error=True,name='mock_report',arguments={})
call('tasks/get',expect_error=True,taskId=a)
meta = old
meta['io.modelcontextprotocol/clientCapabilities']['elicitation'] = {}
e = job()
meta['io.modelcontextprotocol/clientCapabilities'].pop('elicitation')
call('tasks/get',expect_error=True,taskId=e)
meta['io.modelcontextprotocol/clientCapabilities']['elicitation'] = {}
call('tasks/cancel',taskId=e)
p.stdin.close();p.wait()
(root/'trace.json').write_text(json.dumps(trace,ensure_ascii=False,indent=2))
print('PASS: accept, cancel, late cancel, late input, decline, restart lookup, deadline, missing capability')
print('Scope: local stdio mock only; not a Claude Code UI integration run')
我們執行的是這個本機 Mock 與重播,保留每筆 JSON 請求/回覆;沒有執行 Claude Code 的登入、互動視窗或真實遠端報表整合。下節的產品操作差異來自官方文件,是你自己的整合驗收題目,不能把本機斷言通過等同所有 Client 相容。
先追一張收據:tools/call 回 working;第一次 tasks/get 回 input_required;你回答後,下一次查詢回 completed 並帶結果。答案的實際形狀如下,title-1 底下直接是 elicitation 結果,不要多包一層 result。
{
"taskId": "替換成實際 taskId",
"inputResponses": {
"title-1": {
"action": "accept",
"content": {
"title": "Alpha"
}
}
}
}
這個對應方式可核對 InputResponses 規格。本例的 title-1 在每個 task 內只問一次;重複答案、陌生 key 或終態後的遲到答案,都不再觸發完成。正式流程若要再問一次,應換新的 key,避免 Client 的去重邏輯把新問題當成舊問題。
MCP Tasks 教學第五步:互動、開著 /tasks、無人值守分開驗收
先在自己的終端執行 claude --version 記錄版本,再準備符合 Tasks 條件的測試連線。以下操作觀察依 官方補問與背景任務說明整理。每種情境都保留 task ID、Server 收到的 method 與最後狀態,判讀才有依據。
- 互動終端:讓同一個假報表走到補問,回答標題,再核對 Server 收到的
tasks/update。拒絕或在補問視窗按Esc也是一種答覆;後續是否繼續,由 Server 的業務規則決定。本例選擇取消。 - 開著
/tasks:官方文件說補問視窗此時會隱藏;關閉/tasks才能回答。看不到視窗時,先核對這個介面狀態,別立刻重跑報表。 - 非互動的 Tasks:官方記載,任務要求輸入時,Claude Code 會請求 Server 取消,並跳過
Elicitationhooks 與 SDK 的onElicitationcallback。不要把「有 callback」當成這條背景補問路徑一定能自動回答。
還有一個常見誤會:一般一次性的 claude -p "提示" 不會取得 MCP Tasks。文件列出的非互動 Tasks 路徑涉及 Agent SDK 或 --input-format stream-json、CLAUDE_AUTO_BACKGROUND_TASKS=1 等條件,而且要保持輸入開啟。這與「一般非互動工具呼叫也可能需要補問」是兩個情境,測試時要先記下自己走哪條路。
第六步:演練退出、續接與取消競態
按取消後,先把兩件事寫成不同欄位:cancel_requested 表示 Client 已提出要求;最後狀態與成果紀錄 表示 Server 最終做了什麼。Tasks extension 的取消是合作式的;確認訊息可以先回,任務也可能已經完成。
本機重播刻意測兩個相反順序:先完成再取消,最後仍是 completed;先取消再收到答案,最後仍是 cancelled。它不是多 worker 同時搶鎖的壓力測試,而是用順序重播證明終態處理規則。正式 worker 應在同一交易或條件更新內決定誰先取得終態,避免兩個程序各自宣稱成功。
Claude Code 的 /tasks 選中任務後按 x 會停止跟隨並要求取消;Server 仍可能完成。退出時,遠端 Server 的工作可以繼續,stdio 子程序則會停止;續接同一個 session 與維持相同 Server 設定也很重要。以上是 官方退出與續接規則,不是「把筆電關掉後所有工作都能續跑」的保證。
重播裡的重新啟動,只驗證 SQLite 中的收據與待補問狀態能被下一個 Mock 程序查回;它沒有模擬遠端 worker 持續執行。若要做夜間批次工作,請把 worker 放在獨立服務,讓 Client 的連線壽命與工作的壽命分開,再替 worker 設總截止時間與暫存資源回收。
把無人值守做穩:三條規則先寫進 Server
- 先收齊必要輸入,再排入夜間佇列。若報表名稱是必填,就在啟動時收齊;真的需要人判斷的權限,不可把缺席當同意。只有事先核准、可回復的選項才適合備援預設。
- 把截止與保留分開。工作截止後禁止新增副作用;收據仍可留給排查。保留多久、誰能查、何時刪,另訂規則。清理器也要有自己的可觀察紀錄。
- 取消後查外部結果。真正報表若已上傳,取消不會自動收回已送出的檔案。保留 operation ID 與目的系統的結果 ID,再決定刪除、補償或接受已完成結果。
如果你已經遇過逾時重試造成重複執行,可接著看 MCP 逾時結果驗證。那篇處理「事情到底做了沒有」;本篇處理「工作等待補問與取消時,訊息和狀態怎麼走」。兩者接起來,才有完整的運維視角。
常見問題:八個新手最容易混淆的地方
Tasks 是模型自己在背景想事情嗎?
不是。這裡的 Task 由 MCP Server 管理,Client 拿 ID 查進度。模型、Client 與真正 worker 是三個不同角色。
收到 handle 就代表報表完成嗎?
不代表。handle 代表已建立可查詢的任務;完成結果要等終態與對應 result。
表單可以要求 API Key 嗎?
不可以。Elicitation 規格明令禁止 form 模式索取密碼、API Key、access token 或付款憑證;涉及這些秘密的互動要用規定的 URL 模式。此範例只收假標題。
拒絕補問就一定取消嗎?
不一定。Server 決定拒絕後的業務路徑,本例保守地進入 cancelled;不要把示範策略寫成協定通則。
ttlMs 到期就一定停工嗎?
不應這樣解讀。它描述任務資料的 TTL;本例另設工作 deadline,正式 worker 另行執行停止與回收。
按 x 後可以立刻顯示已停止嗎?
應先顯示已要求取消。要宣稱已停止,還得有 Server 或 worker 的證據;Client 可能已不再追蹤結果。
completed 代表業務成功嗎?
不一定。新版 Tasks 可在 completed 的 result 裡帶工具層的 isError;JSON-RPC 執行錯誤則對應 failed。畫面應分辨「已結束」與「有成功成果」。
本例可以直接放上公開網路嗎?
先補上服務邊界。本例是單使用者 stdio 練習器;公開 HTTP 服務需要逐請求身分授權、task 所有權、配額、傳輸規範與獨立 worker,不能只替 Python 程式開一個 port。
給新手的三個重點
- 先把一張收據從 working 追到 input_required,再讓同一個問題 key 收到一份答案。
- 把「已要求取消」與「已停止」分開,重播兩種先後順序,確認終態不被遲到訊息覆蓋。
- 把無人值守當成事先設計的執行模式:必要輸入、權限、截止與清理都要先有答案。
接著閱讀
左右滑動查看更多推薦
下一步:先回答「人不在,這件事應該怎麼結束?」
今天先跑一次假報表,讀 trace.json,找出同一個 task ID 的建立、補問與終態。再故意先取消、後送答案,確認它仍停在 cancelled。當你能解釋這段紀錄,就已經抓到本文的核心:收據讓工作可回查,補件讓輸入可追蹤,停止要由執行端落實。
接著從 AlphaLab AI 專區選一篇系統實作延伸;想把這些練習接成完整工作流,也可以到 AlphaLab 課程繼續學。先讓一份假工作可靠地結束,再交出下一件真工作。





