On the train, an engineer asks their agent to "run the full integration suite and deploy to staging if it's green". It takes 25 minutes.
The server answers with resultType: "task", so the phone just stores taskId. The train enters a tunnel and the connection dies. Nothing is lost: the task lives on the server.
At minute 18, the task moves to input_required: "3 flaky tests failed. Deploy anyway?" The engineer, now at their desk with a laptop, sees the question on the next tasks/get and answers "no" with tasks/update. Same task ID, different device, no session in sight.
Opt in: the client lists the extension in each request's clientCapabilities.extensions, and the server advertises it in server/discover. A server must never return a task to a client that didn't declare it.
Server decides: for a supported request, the server may answer with resultType: "task" and a task handle (taskId, status, ttlMs, pollIntervalMs), with no per-request flag needed.
Poll with tasks/get. Blocking tasks/result is removed, and so is tasks/list.
Mid-flight input: the status changes to input_required with inputRequests, and the client answers with the newtasks/update.
Cancel with tasks/cancel. It's cooperative, so the task may still finish.
The statuses are working, input_required, completed, failed and cancelled; the last three are terminal. Status pushes are available via subscriptions/listen as notifications/tasks, each carrying the full task state.
Holding a connection open while a job runs breaks on timeouts, crashes and flaky networks. A durable task ID survives all three. Moving Tasks to an extension keeps the core small, and lets the feature change on its own schedule.
// ① opt in on the request"_meta": { "io.modelcontextprotocol/clientCapabilities": {"extensions": { "io.modelcontextprotocol/tasks": {} } } }// ② the server chooses to answer with a handle{"jsonrpc":"2.0", "id":11, "result": {"resultType":"task","taskId":"t-123","status":"working","createdAt":"2026-09-01T10:30:00Z","lastUpdatedAt":"2026-09-01T10:30:00Z","ttlMs":3600000,"pollIntervalMs":5000}}// ③ poll, ④ answer a mid-flight question, ⑤ optionally cancel{"jsonrpc":"2.0", "id":12, "method":"tasks/get","params": { "taskId":"t-123", "_meta": { … per-request fields … } }}{"jsonrpc":"2.0", "id":13, "method":"tasks/update","params": { "taskId":"t-123", "inputResponses": { … }, "_meta": { … } }}{"jsonrpc":"2.0", "id":14, "method":"tasks/cancel","params": { "taskId":"t-123", "_meta": { … } }}// update and cancel are acknowledged with an empty result: { "resultType": "complete" }
What it used to look like (legacy, for comparison only)
Legacybefore
// 2025-11-25: experimental, in the core protocol{"jsonrpc":"2.0", "id":11, "method":"tasks/result", "params": { "taskId":"t-123" }}// ↑ blocked until the task finished{"jsonrpc":"2.0", "id":12, "method":"tasks/list"}
Shapes from the ext-tasks schema (schema/2026-07-28/schema.ts). The task fields sit directly on the result, not under a "task" key (that nested key was the 2025-11-25 shape), and createdAt and lastUpdatedAt are required. ttlMs may be null, and pollIntervalMs is optional.
First time here? Set up the test helper (once per terminal)
ShellSetup
# 1. In a SECOND terminal, start the reference server (Node 18+, no dependencies)curl -sO https://www.diegozuluaga.dev/mcpa/reference-server.mjsnode reference-server.mjs # http://localhost:3000/mcp, logs appear here# 2. In THIS terminal, define the helper every test uses (bash or zsh)export MCP=http://localhost:3000/mcpMETA='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'mcp() { # usage: mcp <method> '<json body>' [extra curl args...] curl -sS -N "$MCP" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H "Authorization: Bearer ${MCP_USER:-alice}" \ -H "Mcp-Method: $1" "${@:3}" -d "$2" \ -w '\nHTTP %{http_code}\n'}# Demo auth: the reference server treats the bearer token as the user's name.# Prefix a command with MCP_USER=bob to act as someone else.
ShellTests
META_T='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/tasks":{}}}}'# 1. The server advertises the extensionmcp server/discover '{"jsonrpc":"2.0","id":"d","method":"server/discover","params":{'"$META"'}}' | grep -o 'io.modelcontextprotocol/tasks'# 2. With opt-in, the slow tool answers at once with resultType "task"TASK=$(mcp tools/call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"build_report","arguments":{"month":"2026-09"},'"$META_T"'}}' -H 'Mcp-Name: build_report' | tee /dev/stderr | sed -n 's/.*"taskId":"\([^"]*\)".*/\1/p')# 3. Poll (repeat every few seconds): working, then input_required after about 8 smcp tasks/get '{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"taskId":"'"$TASK"'",'"$META_T"'}}'# 4. Answer the mid-flight question, then keep polling until "completed"mcp tasks/update '{"jsonrpc":"2.0","id":3,"method":"tasks/update","params":{"taskId":"'"$TASK"'","inputResponses":{"charts":{"action":"accept","content":{"charts":true}}},'"$META_T"'}}'# 5. Without opt-in ($META) the same call does NOT become a task: it streams for 10 s instead# 6. Someone else's task: MCP_USER=bob mcp tasks/get ... must be rejected
Answer all 3 correctly and this lesson is marked as learned.
Q1Which statement about Tasks in 2026-07-28 is true?
Q2Spot the bug: a client that did NOT list io.modelcontextprotocol/tasks in its capabilities calls build_report, and the server answers with resultType "task".
Q3Your phone loses its connection while task t-123 is working. After reconnecting, what should the client do?