Saltar al contenido principal

MCP Tasks: Long-Running Work Without the Session

Avanzado

The stateless MCP 2026-07-28 spec solved horizontal scaling by killing the session — but it also killed the easy answer to "what if the tool takes 20 minutes to run?" The answer is the Tasks extension (io.modelcontextprotocol/tasks, SEP-2663): the server returns a durable task handle instead of blocking, and the client drives the work with tasks/get, tasks/update, and tasks/cancel. This is the pattern every serious long-running MCP server ships against in the second half of 2026.

What you'll learn
  • Why blocking a request stops working the moment your server sits behind a load balancer or a serverless runtime
  • The five task states — working, input_required, completed, failed, cancelled — and which transitions are legal
  • The wire protocol: capability negotiation, CreateTaskResult, tasks/get polling, and notifications/tasks push
  • How input_required replaces old-style elicitation without a persistent connection
  • Migrating from the 2025-11-25 experimental Tasks API — why it is a rewrite, not an upgrade
  • The gotchas: cooperative cancellation, tasks/list being intentionally gone, TTL expiry, and cross-tenant leaks

The one paragraph version

A stateless MCP server cannot rely on a long-held connection: HTTP intermediaries drop it, load balancers reshuffle the client to a new instance, mobile networks blink. Tasks turn a long tool call into a durable resource — a taskId your server persists before it even answers the first request. The client polls tasks/get(taskId) on the interval the server suggested; when the status flips to completed, failed, or cancelled, the poll response carries the same payload a synchronous call would have returned. Mid-flight, the server can go to input_required and ask a question — the client answers with tasks/update and polling resumes. That is the entire model.

Why not just block?

You can hold a connection open until the work finishes. The MCP working group considered this and rejected it — for reasons every serverless developer already knows:

Watch out
  • Timeouts. AWS API Gateway caps at 29 s. Cloudflare Workers at 30 s CPU + 6 min wall. Vercel Functions at 5 min. Long-poll a batch import through any of them and you get a 504 halfway.
  • Crash resilience. If the client tab reloads or the network drops, a blocked call loses its result. A taskId is durable — the same client can resume polling minutes later.
  • Load balancer stickiness. Blocking pins the request to one server instance. Every scale-in event during the operation kills the call.
  • Progress visibility. A blocked call gives you nothing until it finishes. A task carries a status message you can render as a progress bar.
  • Mid-flight input. If the tool needs a user confirmation, a blocked call has no way to ask without unsolicited server → client messages — which the stateless spec forbids.

The five-state lifecycle

Every task lives in exactly one of these states. completed, failed, and cancelled are terminal — once reached, the state does not change:

StatusMeaningPopulates
workingOperation in progress. Server updates the optional status message as it goes.statusMessage
input_requiredServer is blocked waiting on client input. Present the request, submit via tasks/update.inputRequests
completedOperation finished successfully. result holds what a sync call would have returned.result
failedJSON-RPC error occurred during execution.error
cancelledClient requested cancellation and the server honored it. Not guaranteed on every request.

Legal transitions: working ↔ input_required, working → completed | failed | cancelled, input_required → working | failed | cancelled. Anything else is a server bug.

The wire protocol

1. Both sides opt in

Tasks is an extension, not core — both sides must advertise it. The client puts it in every request's _meta; the server returns it from server/discover:

// Client → server on any request that MIGHT come back as a task:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "run_ci_pipeline",
"arguments": { "commit": "abc123" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}

If the client did not declare support, the server must not return a task — it has to either block, return an error, or refuse the operation. Never send a CreateTaskResult to a client that did not opt in.

2. Server returns a task handle

Instead of the normal CallToolResult, the server answers with resultType: "task":

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "task",
"task": {
"taskId": "tsk_01HZY7...",
"status": "working",
"statusMessage": "Cloning repo",
"ttlMs": 3600000,
"pollIntervalMs": 2000
}
}
}

The task must be durably persisted (Postgres, Redis with AOF, DynamoDB — anything that survives a pod restart) before the server sends this response. If the server crashes between accepting the request and persisting the task, the client gets a normal error and can retry. If it crashes after, the taskId is still resolvable from any replica.

3. Client polls tasks/get

// Client → server, every pollIntervalMs:
{ "jsonrpc": "2.0", "id": 2, "method": "tasks/get", "params": { "taskId": "tsk_01HZY7..." } }

// Server → client, still in flight:
{ "jsonrpc": "2.0", "id": 2, "result": { "taskId": "tsk_01HZY7...", "status": "working", "statusMessage": "Running tests (128/342)" } }

// Server → client, terminal:
{ "jsonrpc": "2.0", "id": 2, "result": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "content": [{ "type": "text", "text": "All 342 tests passed in 4m12s" }] } } }

pollIntervalMs is a suggestion — clients should honor it as a floor, back off on working responses that repeat, and never poll faster than the server asked.

4. Mid-flight input

If the tool needs a user confirmation ("this will delete 47 files, continue?"), the server flips to input_required and attaches an inputRequests map — the same shape an elicitation would take in the pre-stateless world:

// tasks/get response:
{
"taskId": "tsk_01HZY7...",
"status": "input_required",
"inputRequests": {
"confirm_delete": {
"type": "elicitation",
"message": "Delete 47 files matching *.tmp?",
"schema": { "type": "object", "properties": { "confirm": { "type": "boolean" } } }
}
}
}

The client shows the prompt, then answers with tasks/update:

{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/update",
"params": {
"taskId": "tsk_01HZY7...",
"inputResponses": { "confirm_delete": { "confirm": true } }
}
}

Server acks with an empty result; state moves back to working. Responses for unknown or already-satisfied keys must be ignored — this makes retries safe.

5. Cooperative cancellation

{ "jsonrpc": "2.0", "id": 9, "method": "tasks/cancel", "params": { "taskId": "tsk_01HZY7..." } }

Server acks with an empty result. Note the wording in the spec: cancellation is cooperative — the server acknowledges the intent but is not obligated to stop the work. A tasks/cancel on a task about to hit completed may still land as completed. Design your client UI around "cancellation requested, waiting for confirmation" not "cancelled". This is the single most common source of user-visible bugs during migration.

Notifications instead of polling

Polling is the default and it always works. When a server does support notifications, the client can subscribe once and skip the polling loop entirely:

// Client subscribes to task change events:
{ "jsonrpc": "2.0", "id": 3, "method": "subscriptions/listen", "params": { "notifications": ["notifications/tasks"] } }

// Server pushes a full task snapshot on every state change:
{ "jsonrpc": "2.0", "method": "notifications/tasks", "params": { "task": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "..." : "..." } } } }

Each push carries the entire task state — clients never need a follow-up tasks/get. Fall back to polling if subscriptions/listen returns "not supported" or the stream disconnects.

When to use Tasks (and when not)

Guided walkthrough1 of 6
  1. CI pipelines, batch imports, model training, video encoding, large refactors, deployments. If p99 is over ~10 seconds you already want Tasks; if p99 is over 30 seconds you are already broken without them.

Migrating from the 2025-11-25 experimental Tasks API

The old tasks/create / tasks/status shape from the pre-stateless spec is not compatible with SEP-2663. Treat it as a rewrite, not a version bump:

Watch out
  • Old: client explicitly called tasks/create. New: any tools/call MAY come back as a task — the client MUST handle a polymorphic result on every request.
  • Old: tasks/list enumerated tasks for a session. New: tasks/list is intentionally removed — a stateless server has no session to scope by, and listing across tenants is a data leak. Track your own task IDs client-side or in your product database.
  • Old: elicitation was a separate server → client push. New: elicitation folds into the task as input_required — no unsolicited pushes needed.
  • Old: status was one of {pending, running, done, error}. New: {working, input_required, completed, failed, cancelled}. Map error → failed and add the new input_required branch.
  • Deprecation clock: the experimental API keeps working through July 28, 2027 minimum. Rewrite against SEP-2663, run both endpoints in parallel, cut over on your own schedule.

Server implementation checklist

Guided walkthrough1 of 6
  1. The CreateTaskResult is a promise the client will be able to poll. If your DB write happens after the HTTP response, a crash between the two breaks that promise. Write-through, then respond.

Client implementation checklist

Guided walkthrough1 of 5
  1. The moment you opt into Tasks, ANY tool call can come back as a task. A single ignored resultType: task branch means silently dropped results.

A real example: a run_migration tool

Server pseudocode — a tool that runs a 5-30 minute DB migration

// tools/call handler
async function handleToolCall(req) {
const supportsTasks = req.params._meta
  ?.["io.modelcontextprotocol/clientCapabilities"]
  ?.extensions?.["io.modelcontextprotocol/tasks"];

if (req.params.name === "run_migration") {
  if (!supportsTasks) {
    return jsonRpcError(req.id, -32603, "run_migration requires Tasks extension");
  }
  const taskId = "tsk_" + ulid();
  await db.tasks.insert({
    id: taskId, tenant: req.auth.tenant, status: "working",
    createdAt: Date.now(), ttlMs: 24 * 3600 * 1000,
  });
  // Kick off the actual work OUT OF BAND — do not await it here.
  queue.enqueue({ taskId, migration: req.params.arguments.name });
  return {
    resultType: "task",
    task: { taskId, status: "working", ttlMs: 24 * 3600 * 1000, pollIntervalMs: 5000 },
  };
}
}

// tasks/get handler — scoped by authenticated tenant
async function handleTasksGet(req) {
const t = await db.tasks.findOne({ id: req.params.taskId, tenant: req.auth.tenant });
if (!t) return jsonRpcError(req.id, -32602, "unknown taskId");
if (Date.now() > t.createdAt + t.ttlMs) return jsonRpcError(req.id, -32602, "task expired");
return { taskId: t.id, status: t.status, statusMessage: t.statusMessage,
         ...(t.status === "completed" && { result: t.result }),
         ...(t.status === "failed" && { error: t.error }) };
}

Gotchas most teams hit in week one

Watch out
  • 'tasks/list is missing.' Yes — on purpose. There is no session to scope it. Track task IDs in your own product database.
  • 'My cancel button lies.' It always will. Rename it 'Request cancellation' or gate the state change on the server's terminal ack.
  • 'I only get results when I poll.' Right — until you also implement notifications/tasks + subscriptions/listen. Both paths, always.
  • 'The Python SDK does not have a helper for this yet.' Some Tier 1 SDK helpers are still stabilizing. You can always implement the raw JSON-RPC by hand — the wire format is fully specified.
  • 'A user pulled another user's task by guessing the ID.' Because you forgot to scope by tenant on tasks/get. Every handler MUST filter by the authenticated principal.

Quiz

Check yourself

0/4
  1. Your client did NOT include io.modelcontextprotocol/tasks in its request _meta. The tool it called takes 20 minutes. What should the server do?
  2. A user hits 'Cancel' on a task 100 ms before it completes. Your server processes the cancel and the completion at the same time. What state can the task legally end up in?
  3. You are migrating from the 2025-11-25 experimental Tasks API. Your old code calls tasks/list to show a queue. What is the correct fix?
  4. Which of these is the RIGHT reason to reach for MRTR (SEP-2322) instead of Tasks?

Flashcards

Pulsa Intro o Espacio para girar la tarjeta. Usa las flechas izquierda y derecha para moverte entre las tarjetas.Término mostrado.
1 / 8

Sources & further reading