Aller au contenu principal

MCP Tasks : travail long sans la session

Avancé

La spec MCP sans état 2026-07-28 a résolu le scaling horizontal en tuant la session — mais elle a aussi tué la réponse facile à « et si l'outil prend 20 minutes à tourner ? ». La réponse est l'extension Tasks (io.modelcontextprotocol/tasks, SEP-2663) : le serveur retourne un handle de tâche durable au lieu de bloquer, et le client pilote le travail avec tasks/get, tasks/update et tasks/cancel. C'est le pattern contre lequel chaque serveur MCP long sérieux livre dans la seconde moitié de 2026.

What you'll learn
  • Pourquoi bloquer une requête cesse de marcher dès que votre serveur s'assied derrière un load balancer ou un runtime serverless
  • Les cinq états de tâche — working, input_required, completed, failed, cancelled — et quelles transitions sont légales
  • Le protocole wire : négociation de capacité, CreateTaskResult, polling tasks/get, et push notifications/tasks
  • Comment input_required remplace l'ancienne elicitation sans connexion persistante
  • Migrer depuis l'API Tasks expérimentale 2025-11-25 — pourquoi c'est une réécriture, pas une mise à jour
  • Les pièges : annulation coopérative, tasks/list intentionnellement disparu, expiration TTL, et fuites cross-tenant

La version en un paragraphe

Un serveur MCP sans état ne peut pas compter sur une connexion long-tenue : les intermédiaires HTTP la coupent, les load balancers rebattent le client vers une nouvelle instance, les réseaux mobiles clignent. Tasks transforme un long appel d'outil en ressource durable — un taskId que votre serveur persiste avant même de répondre à la première requête. Le client poll tasks/get(taskId) à l'intervalle que le serveur a suggéré ; quand le statut bascule à completed, failed, ou cancelled, la réponse de poll porte la même charge utile qu'un appel synchrone aurait retournée. En pleine tâche, le serveur peut aller à input_required et poser une question — le client répond avec tasks/update et le polling reprend. C'est tout le modèle.

Pourquoi ne pas juste bloquer ?

Vous pouvez tenir une connexion ouverte jusqu'à ce que le travail finisse. Le groupe de travail MCP a considéré ça et l'a rejeté — pour des raisons que tout développeur serverless connaît déjà :

Watch out
  • Timeouts. AWS API Gateway plafonne à 29 s. Cloudflare Workers à 30 s CPU + 6 min wall. Vercel Functions à 5 min. Long-poll un import batch à travers l'un d'eux et vous obtenez un 504 à mi-chemin.
  • Résilience au crash. Si l'onglet client recharge ou que le réseau coupe, un appel bloqué perd son résultat. Un taskId est durable — le même client peut reprendre le polling minutes plus tard.
  • Stickiness du load balancer. Bloquer épingle la requête à une instance serveur. Chaque événement de scale-in pendant l'opération tue l'appel.
  • Visibilité de progression. Un appel bloqué ne vous donne rien jusqu'à ce qu'il finisse. Une tâche porte un message de statut que vous pouvez rendre comme barre de progression.
  • Entrée en pleine tâche. Si l'outil a besoin d'une confirmation utilisateur, un appel bloqué n'a aucun moyen de demander sans messages serveur → client non sollicités — que la spec sans état interdit.

Le cycle de vie à cinq états

Chaque tâche vit dans exactement un de ces états. completed, failed, et cancelled sont terminaux — une fois atteints, l'état ne change pas :

StatutSignificationPeuple
workingOpération en cours. Le serveur met à jour le message de statut optionnel au fur et à mesure.statusMessage
input_requiredLe serveur est bloqué en attente d'entrée client. Présenter la demande, soumettre via tasks/update.inputRequests
completedOpération finie avec succès. result contient ce qu'un appel sync aurait retourné.result
failedErreur JSON-RPC survenue pendant l'exécution.error
cancelledLe client a demandé l'annulation et le serveur l'a honorée. Pas garanti à chaque requête.

Transitions légales : working ↔ input_required, working → completed | failed | cancelled, input_required → working | failed | cancelled. Tout autre chose est un bug serveur.

Le protocole wire

1. Les deux côtés optent pour

Tasks est une extension, pas core — les deux côtés doivent l'annoncer. Le client la met dans le _meta de chaque requête ; le serveur la retourne depuis 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": {}
}
}
}
}
}

Si le client n'a pas déclaré le support, le serveur ne doit pas retourner de tâche — il doit soit bloquer, retourner une erreur, ou refuser l'opération. N'envoyez jamais un CreateTaskResult à un client qui n'a pas opté pour.

2. Le serveur retourne un handle de tâche

Au lieu du CallToolResult normal, le serveur répond avec resultType: "task" :

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

La tâche doit être persistée durablement (Postgres, Redis avec AOF, DynamoDB — n'importe quoi qui survit à un restart de pod) avant que le serveur n'envoie cette réponse. Si le serveur crashe entre l'acceptation de la requête et la persistance de la tâche, le client obtient une erreur normale et peut réessayer. S'il crashe après, le taskId est encore résoluble depuis n'importe quelle réplique.

3. Le client poll 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 est une suggestion — les clients devraient l'honorer comme un plancher, back-off sur les réponses working qui se répètent, et ne jamais poller plus vite que ce que le serveur a demandé.

4. Entrée en pleine tâche

Si l'outil a besoin d'une confirmation utilisateur (« ceci va supprimer 47 fichiers, continuer ? »), le serveur bascule à input_required et attache une map inputRequests — la même forme qu'une elicitation prendrait dans le monde pré-sans-état :

// 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" } } }
}
}
}

Le client montre le prompt, puis répond avec tasks/update :

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

Le serveur ack avec un résultat vide ; l'état repart à working. Les réponses pour des clés inconnues ou déjà satisfaites doivent être ignorées — ça rend les retries sûrs.

5. Annulation coopérative

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

Le serveur ack avec un résultat vide. Notez la formulation dans la spec : l'annulation est coopérative — le serveur reconnaît l'intention mais n'est pas obligé d'arrêter le travail. Un tasks/cancel sur une tâche sur le point d'atteindre completed peut encore atterrir en completed. Concevez votre UI client autour de « annulation demandée, en attente de confirmation » pas « annulé ». C'est la source unique la plus commune de bugs visibles pour l'utilisateur pendant la migration.

Notifications au lieu de polling

Le polling est le défaut et marche toujours. Quand un serveur supporte les notifications, le client peut s'abonner une fois et sauter entièrement la boucle de polling :

// 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": { "..." : "..." } } } }

Chaque push porte l'entier état de tâche — les clients n'ont jamais besoin d'un tasks/get de suivi. Repli sur le polling si subscriptions/listen retourne « non supporté » ou si le stream se déconnecte.

Quand utiliser Tasks (et quand pas)

Guided walkthrough1 of 6
  1. Pipelines CI, imports batch, entraînement de modèles, encodage vidéo, gros refactorings, déploiements. Si p99 dépasse ~10 secondes vous voulez déjà Tasks ; si p99 dépasse 30 secondes vous êtes déjà cassé sans eux.

Migrer depuis l'API Tasks expérimentale 2025-11-25

L'ancienne forme tasks/create / tasks/status de la spec pré-sans-état n'est pas compatible avec SEP-2663. Traitez ça comme une réécriture, pas une bump de version :

Watch out
  • Ancien : le client appelait explicitement tasks/create. Nouveau : tout tools/call PEUT revenir comme une tâche — le client DOIT gérer un résultat polymorphe sur chaque requête.
  • Ancien : tasks/list énumérait les tâches pour une session. Nouveau : tasks/list est intentionnellement retiré — un serveur sans état n'a pas de session à scoper, et lister à travers les tenants est une fuite de données. Tracez vos propres task IDs côté client ou dans votre base de données produit.
  • Ancien : l'elicitation était un push serveur → client séparé. Nouveau : l'elicitation se replie dans la tâche comme input_required — pas de pushes non sollicités nécessaires.
  • Ancien : le statut était un de {pending, running, done, error}. Nouveau : {working, input_required, completed, failed, cancelled}. Cartographiez error → failed et ajoutez la nouvelle branche input_required.
  • Horloge de dépréciation : l'API expérimentale continue de marcher au moins jusqu'au 28 juillet 2027. Réécrivez contre SEP-2663, faites tourner les deux endpoints en parallèle, basculez à votre propre rythme.

Checklist d'implémentation serveur

Guided walkthrough1 of 6
  1. Le CreateTaskResult est une promesse que le client pourra poller. Si votre écriture DB arrive après la réponse HTTP, un crash entre les deux casse cette promesse. Write-through, puis répondre.

Checklist d'implémentation client

Guided walkthrough1 of 5
  1. Le moment où vous optez pour Tasks, TOUT appel d'outil peut revenir comme une tâche. Une seule branche resultType: task ignorée signifie des résultats silencieusement lâchés.

Un exemple réel : un outil run_migration

Pseudocode serveur — un outil qui fait tourner une migration DB de 5-30 minutes

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

Pièges que la plupart des équipes heurtent en première semaine

Watch out
  • 'tasks/list manque.' Oui — exprès. Il n'y a pas de session à scoper. Tracez les task IDs dans votre propre base de données produit.
  • 'Mon bouton cancel ment.' Il le fera toujours. Renommez-le 'Demander l'annulation' ou gatez le changement d'état sur l'ack terminal du serveur.
  • 'Je n'obtiens des résultats que quand je poll.' Correct — jusqu'à ce que vous implémentiez aussi notifications/tasks + subscriptions/listen. Les deux chemins, toujours.
  • 'Le SDK Python n'a pas encore de helper pour ça.' Certains helpers de SDKs Tier 1 se stabilisent encore. Vous pouvez toujours implémenter le JSON-RPC brut à la main — le format wire est entièrement spécifié.
  • 'Un utilisateur a tiré la tâche d'un autre utilisateur en devinant l'ID.' Parce que vous avez oublié de scoper par tenant sur tasks/get. Chaque handler DOIT filtrer par le principal authentifié.

Quiz

Check yourself

0/4
  1. Votre client n'a PAS inclus io.modelcontextprotocol/tasks dans le _meta de sa requête. L'outil qu'il a appelé prend 20 minutes. Que doit faire le serveur ?
  2. Un utilisateur appuie sur 'Cancel' sur une tâche 100 ms avant qu'elle ne se complète. Votre serveur traite le cancel et la complétion en même temps. Dans quel état la tâche peut-elle légalement finir ?
  3. Vous migrez depuis l'API Tasks expérimentale 2025-11-25. Votre ancien code appelle tasks/list pour montrer une queue. Quel est le bon fix ?
  4. Laquelle de celles-ci est la BONNE raison de se saisir de MRTR (SEP-2322) au lieu de Tasks ?

Flashcards

Appuyez sur Entrée ou Espace pour retourner la carte. Utilisez les flèches gauche et droite pour naviguer entre les cartes.Terme affiché.
1 / 8

Sources et lectures complémentaires