Passa al contenuto principale

MCP Tasks: lavoro long-running senza sessione

Avanzato

La spec stateless MCP 2026-07-28 ha risolto lo scaling orizzontale eliminando la sessione — ma ha anche eliminato la risposta facile alla domanda "e se il tool impiega 20 minuti?". La risposta è l'estensione Tasks (io.modelcontextprotocol/tasks, SEP-2663): il server restituisce un task handle durabile invece di bloccarsi, e il client guida il lavoro con tasks/get, tasks/update e tasks/cancel. Questo è il pattern con cui ogni server MCP long-running serio si distribuisce nella seconda metà del 2026.

What you'll learn
  • Perché bloccare una richiesta smette di funzionare nel momento in cui il tuo server sta dietro a un load balancer o a un runtime serverless
  • I cinque stati del task — working, input_required, completed, failed, cancelled — e quali transizioni sono legali
  • Il protocollo wire: negoziazione delle capability, CreateTaskResult, polling con tasks/get e push con notifications/tasks
  • Come input_required sostituisce la vecchia elicitation senza una connessione persistente
  • Migrazione dalla Tasks API sperimentale del 2025-11-25 — perché è una riscrittura, non un upgrade
  • Le insidie: cancellazione cooperativa, tasks/list volutamente rimosso, scadenza TTL e leak cross-tenant

La versione da un paragrafo

Un server MCP stateless non può contare su una connessione tenuta aperta a lungo: gli intermediari HTTP la droppano, i load balancer rimescolano il client su una nuova istanza, le reti mobili si interrompono. I Task trasformano una tool call lunga in una risorsa durabile — un taskId che il tuo server persiste prima ancora di rispondere alla prima richiesta. Il client fa polling di tasks/get(taskId) all'intervallo suggerito dal server; quando lo status passa a completed, failed o cancelled, la risposta del poll trasporta lo stesso payload che avrebbe restituito una chiamata sincrona. A metà lavoro, il server può passare a input_required e fare una domanda — il client risponde con tasks/update e il polling riprende. Questo è tutto il modello.

Perché non bloccare e basta?

Puoi tenere una connessione aperta finché il lavoro finisce. Il working group MCP l'ha considerato e rifiutato — per le ragioni che ogni sviluppatore serverless già conosce:

Watch out
  • Timeout. AWS API Gateway limita a 29 s. Cloudflare Workers a 30 s CPU + 6 min wall. Vercel Functions a 5 min. Fai long-poll di un import batch attraverso uno qualsiasi e prendi un 504 a metà strada.
  • Resilienza ai crash. Se la tab del client si ricarica o la rete cade, una chiamata bloccata perde il suo risultato. Un taskId è durabile — lo stesso client può riprendere il polling minuti dopo.
  • Stickiness del load balancer. Bloccare pinna la richiesta su una sola istanza del server. Ogni evento di scale-in durante l'operazione uccide la chiamata.
  • Visibilità del progresso. Una chiamata bloccata non ti dà niente finché non finisce. Un task porta uno status message che puoi renderizzare come progress bar.
  • Input a metà volo. Se il tool ha bisogno di una conferma utente, una chiamata bloccata non ha modo di chiedere senza messaggi non sollecitati server → client — che la spec stateless vieta.

Il lifecycle a cinque stati

Ogni task vive in esattamente uno di questi stati. completed, failed e cancelled sono terminali — una volta raggiunti, lo stato non cambia:

StatusSignificatoPopola
workingOperazione in corso. Il server aggiorna lo status message opzionale mentre procede.statusMessage
input_requiredIl server è bloccato in attesa di input dal client. Presenta la richiesta, invia via tasks/update.inputRequests
completedOperazione conclusa con successo. result contiene ciò che avrebbe restituito una chiamata sync.result
failedSi è verificato un errore JSON-RPC durante l'esecuzione.error
cancelledIl client ha richiesto la cancellazione e il server l'ha onorata. Non garantito su ogni richiesta.

Transizioni legali: working ↔ input_required, working → completed | failed | cancelled, input_required → working | failed | cancelled. Qualsiasi altra cosa è un bug del server.

Il protocollo wire

1. Entrambi i lati optano in

Tasks è un'estensione, non parte del core — entrambi i lati devono dichiararla. Il client la mette nel _meta di ogni richiesta; il server la restituisce da server/discover:

// Client → server su qualsiasi richiesta che POTREBBE tornare come 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": {}
}
}
}
}
}

Se il client non ha dichiarato supporto, il server non deve restituire un task — deve bloccare, restituire un errore o rifiutare l'operazione. Non inviare mai un CreateTaskResult a un client che non ha optato in.

2. Il server restituisce un task handle

Invece del normale CallToolResult, il server risponde con resultType: "task":

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

Il task deve essere persistito durabilmente (Postgres, Redis con AOF, DynamoDB — qualsiasi cosa sopravviva a un restart del pod) prima che il server invii questa risposta. Se il server crasha tra l'accettazione della richiesta e la persistenza del task, il client riceve un errore normale e può ritentare. Se crasha dopo, il taskId è ancora risolvibile da qualsiasi replica.

3. Il client fa polling di tasks/get

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

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

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

pollIntervalMs è un suggerimento — i client dovrebbero onorarlo come limite inferiore, fare backoff sulle risposte working che si ripetono e non fare mai polling più veloce di quanto il server abbia chiesto.

4. Input a metà volo

Se il tool ha bisogno di una conferma utente ("questo cancellerà 47 file, continuare?"), il server passa a input_required e allega una mappa inputRequests — la stessa forma che avrebbe avuto un'elicitation nel mondo pre-stateless:

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

Il client mostra il prompt, poi risponde con tasks/update:

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

Il server risponde con un result vuoto; lo stato torna a working. Le risposte per chiavi sconosciute o già soddisfatte devono essere ignorate — questo rende i retry sicuri.

5. Cancellazione cooperativa

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

Il server risponde con un result vuoto. Nota la formulazione nella spec: la cancellazione è cooperativa — il server riconosce l'intento ma non è obbligato a fermare il lavoro. Un tasks/cancel su un task in procinto di arrivare a completed può comunque finire come completed. Disegna la UI del tuo client attorno a "cancellazione richiesta, in attesa di conferma", non "cancellato". Questa è la singola fonte più comune di bug visibili all'utente durante la migrazione.

Notifiche invece del polling

Il polling è il default e funziona sempre. Quando un server supporta le notifiche, il client può iscriversi una volta e saltare completamente il loop di polling:

// Il client si iscrive agli eventi di cambio task:
{ "jsonrpc": "2.0", "id": 3, "method": "subscriptions/listen", "params": { "notifications": ["notifications/tasks"] } }

// Il server pusha uno snapshot completo del task a ogni cambio di stato:
{ "jsonrpc": "2.0", "method": "notifications/tasks", "params": { "task": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "..." : "..." } } } }

Ogni push porta lo stato intero del task — i client non hanno mai bisogno di un tasks/get di follow-up. Ricadi sul polling se subscriptions/listen restituisce "not supported" o lo stream si disconnette.

Quando usare Tasks (e quando no)

Guided walkthrough1 of 6
  1. Pipeline CI, import batch, training di modelli, encoding video, refactor grandi, deployment. Se il p99 supera i ~10 secondi vuoi già Tasks; se il p99 supera i 30 secondi sei già rotto senza.

Migrazione dalla Tasks API sperimentale del 2025-11-25

La vecchia forma tasks/create / tasks/status dalla spec pre-stateless non è compatibile con SEP-2663. Trattala come una riscrittura, non un version bump:

Watch out
  • Vecchio: il client chiamava esplicitamente tasks/create. Nuovo: qualsiasi tools/call PUÒ tornare come task — il client DEVE gestire un risultato polimorfico su ogni richiesta.
  • Vecchio: tasks/list enumerava i task per una sessione. Nuovo: tasks/list è volutamente rimosso — un server stateless non ha sessione con cui scoparlo, e listare tra i tenant è un data leak. Traccia i tuoi task ID lato client o nel tuo product database.
  • Vecchio: l'elicitation era un push server → client separato. Nuovo: l'elicitation si piega dentro il task come input_required — nessun push non sollecitato necessario.
  • Vecchio: lo status era uno tra {pending, running, done, error}. Nuovo: {working, input_required, completed, failed, cancelled}. Mappa error → failed e aggiungi il nuovo ramo input_required.
  • Deprecation clock: l'API sperimentale continua a funzionare fino ad almeno il 28 luglio 2027. Riscrivi su SEP-2663, fai girare entrambi gli endpoint in parallelo, taglia sul tuo schedule.

Checklist implementazione server

Guided walkthrough1 of 6
  1. Il CreateTaskResult è una promessa che il client potrà fare polling. Se la scrittura DB avviene dopo la risposta HTTP, un crash tra le due rompe quella promessa. Write-through, poi rispondi.

Checklist implementazione client

Guided walkthrough1 of 5
  1. Nel momento in cui opti per Tasks, QUALSIASI tool call può tornare come task. Un singolo ramo resultType: task ignorato significa risultati silenziosamente droppati.

Un esempio reale: un tool run_migration

Pseudocodice server — un tool che esegue una migrazione DB da 5-30 minuti

// 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 }) };
}

Insidie che quasi tutti i team incontrano nella prima settimana

Watch out
  • 'tasks/list manca.' Sì — di proposito. Non c'è sessione con cui scoparlo. Traccia i task ID nel tuo product database.
  • 'Il mio bottone di cancel mente.' Lo farà sempre. Rinominalo 'Richiedi cancellazione' o vincola il cambio di stato all'ack terminale del server.
  • 'Ricevo risultati solo quando faccio polling.' Giusto — finché non implementi anche notifications/tasks + subscriptions/listen. Entrambi i path, sempre.
  • 'L'SDK Python non ha ancora un helper per questo.' Alcuni helper degli SDK Tier 1 si stanno ancora stabilizzando. Puoi sempre implementare il JSON-RPC grezzo a mano — il formato wire è completamente specificato.
  • 'Un utente ha pullato il task di un altro utente indovinando l'ID.' Perché hai dimenticato di scopare per tenant su tasks/get. Ogni handler DEVE filtrare per il principal autenticato.

Quiz

Check yourself

0/4
  1. Il tuo client NON ha incluso io.modelcontextprotocol/tasks nel _meta della sua richiesta. Il tool che ha chiamato impiega 20 minuti. Cosa dovrebbe fare il server?
  2. Un utente preme 'Cancel' su un task 100 ms prima che completi. Il tuo server processa il cancel e il completion nello stesso istante. In quale stato può legalmente finire il task?
  3. Stai migrando dalla Tasks API sperimentale del 2025-11-25. Il tuo vecchio codice chiama tasks/list per mostrare una coda. Qual è il fix corretto?
  4. Quale di queste è la ragione GIUSTA per raggiungere MRTR (SEP-2322) invece di Tasks?

Flashcards

Premi Invio o Spazio per girare la carta. Usa le frecce sinistra e destra per spostarti tra le carte.Termine mostrato.
1 / 8

Fonti & letture ulteriori