Zum Hauptinhalt springen

MCP Tasks: Langlaufende Arbeit ohne Session

Experte

Die stateless MCP 2026-07-28-Spec löste horizontales Skalieren, indem sie die Session tötete — aber sie tötete auch die einfache Antwort auf "was, wenn das Tool 20 Minuten läuft?". Die Antwort ist die Tasks-Extension (io.modelcontextprotocol/tasks, SEP-2663): Der Server gibt ein dauerhaftes Task-Handle zurück, statt zu blockieren, und der Client steuert die Arbeit mit tasks/get, tasks/update und tasks/cancel. Das ist das Muster, gegen das jeder ernsthafte langlaufende MCP-Server in der zweiten Jahreshälfte 2026 ausliefert.

What you'll learn
  • Warum das Blockieren einer Request in dem Moment aufhört zu funktionieren, in dem dein Server hinter einem Load Balancer oder einer Serverless-Runtime sitzt
  • Die fünf Task-States — working, input_required, completed, failed, cancelled — und welche Übergänge legal sind
  • Das Wire-Protocol: Capability-Verhandlung, CreateTaskResult, tasks/get-Polling und notifications/tasks-Push
  • Wie input_required das alte elicitation ohne persistente Verbindung ersetzt
  • Migration von der 2025-11-25 experimentellen Tasks-API — warum es ein Rewrite ist, kein Upgrade
  • Die Fallstricke: kooperatives Cancellation, tasks/list ist absichtlich weg, TTL-Ablauf und Cross-Tenant-Leaks

Die Ein-Absatz-Version

Ein stateless MCP-Server kann sich nicht auf eine lange gehaltene Verbindung verlassen: HTTP-Intermediäre lassen sie fallen, Load Balancer mischen den Client zu einer neuen Instanz um, mobile Netze blinken. Tasks verwandeln einen langlaufenden Tool-Call in eine dauerhafte Resource — eine taskId, die dein Server persistiert, bevor er überhaupt die erste Request beantwortet. Der Client pollt tasks/get(taskId) im vom Server vorgeschlagenen Intervall; wenn der Status auf completed, failed oder cancelled flippt, trägt die Poll-Antwort dieselbe Payload, die ein synchroner Call zurückgegeben hätte. Mitten im Flug kann der Server auf input_required gehen und eine Frage stellen — der Client antwortet mit tasks/update, und das Polling wird fortgesetzt. Das ist das gesamte Modell.

Warum nicht einfach blockieren?

Du kannst eine Verbindung offen halten, bis die Arbeit fertig ist. Die MCP-Arbeitsgruppe hat das erwogen und abgelehnt — aus Gründen, die jeder Serverless-Entwickler bereits kennt:

Watch out
  • Timeouts. AWS API Gateway begrenzt auf 29 s. Cloudflare Workers auf 30 s CPU + 6 min Wall. Vercel Functions auf 5 min. Poll einen Batch-Import durch irgendeinen davon und du bekommst mittendrin ein 504.
  • Crash-Resilienz. Wenn der Client-Tab neu lädt oder das Netzwerk abbricht, verliert ein blockierter Call sein Ergebnis. Eine taskId ist dauerhaft — derselbe Client kann Minuten später das Polling fortsetzen.
  • Load-Balancer-Stickiness. Das Blockieren pinnt die Request an eine Server-Instanz. Jedes Scale-in-Event während der Operation tötet den Call.
  • Fortschritts-Sichtbarkeit. Ein blockierter Call gibt dir nichts, bis er fertig ist. Ein Task trägt eine Status-Message, die du als Progress Bar rendern kannst.
  • Mid-flight-Input. Wenn das Tool eine Nutzerbestätigung braucht, hat ein blockierter Call keinen Weg zu fragen, ohne unaufgeforderte Server → Client-Messages — was die Stateless-Spec verbietet.

Der Fünf-State-Lifecycle

Jeder Task lebt in genau einem dieser States. completed, failed und cancelled sind terminal — einmal erreicht, ändert sich der State nicht mehr:

StatusBedeutungFüllt
workingOperation läuft. Der Server aktualisiert die optionale Status-Message dabei.statusMessage
input_requiredServer ist blockiert und wartet auf Client-Input. Präsentiere die Anfrage, sende über tasks/update.inputRequests
completedOperation erfolgreich beendet. result hält, was ein Sync-Call zurückgegeben hätte.result
failedJSON-RPC-Fehler während der Ausführung aufgetreten.error
cancelledClient hat Cancellation angefordert und der Server hat sie geehrt. Nicht bei jeder Anfrage garantiert.

Legale Übergänge: working ↔ input_required, working → completed | failed | cancelled, input_required → working | failed | cancelled. Alles andere ist ein Server-Bug.

Das Wire-Protocol

1. Beide Seiten opten ein

Tasks ist eine Extension, nicht Core — beide Seiten müssen sie bewerben. Der Client legt sie in das _meta jeder Request; der Server gibt sie von server/discover zurück:

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

Wenn der Client keine Unterstützung deklariert hat, darf der Server keinen Task zurückgeben — er muss entweder blockieren, einen Fehler zurückgeben oder die Operation ablehnen. Sende nie ein CreateTaskResult an einen Client, der nicht opt-in war.

2. Server gibt ein Task-Handle zurück

Statt des normalen CallToolResult antwortet der Server mit resultType: "task":

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

Der Task muss dauerhaft persistiert werden (Postgres, Redis mit AOF, DynamoDB — alles, was einen Pod-Neustart überlebt), bevor der Server diese Antwort sendet. Wenn der Server zwischen dem Akzeptieren der Request und dem Persistieren des Tasks crasht, bekommt der Client einen normalen Fehler und kann erneut versuchen. Wenn er danach crasht, ist die taskId immer noch von jeder Replika auflösbar.

3. Client pollt 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 ist ein Vorschlag — Clients sollten ihn als Untergrenze ehren, bei sich wiederholenden working-Antworten zurückgehen und nie schneller pollen, als der Server gefragt hat.

4. Mid-flight-Input

Wenn das Tool eine Nutzerbestätigung braucht ("dies wird 47 Dateien löschen, fortfahren?"), flippt der Server auf input_required und hängt eine inputRequests-Map an — dieselbe Form, die eine elicitation in der Pre-Stateless-Welt hätte:

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

Der Client zeigt den Prompt und antwortet dann mit tasks/update:

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

Der Server acknowledged mit einem leeren Result; der State geht zurück zu working. Antworten für unbekannte oder bereits erfüllte Keys müssen ignoriert werden — das macht Retries sicher.

5. Kooperatives Cancellation

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

Server acknowledged mit einem leeren Result. Beachte die Formulierung in der Spec: Cancellation ist kooperativ — der Server bestätigt die Absicht, ist aber nicht verpflichtet, die Arbeit zu stoppen. Ein tasks/cancel auf einem Task kurz vor completed kann trotzdem als completed landen. Designe deine Client-UI um "Cancellation angefordert, warten auf Bestätigung", nicht um "cancelled". Das ist die häufigste Quelle nutzer-sichtbarer Bugs während der Migration.

Notifications statt Polling

Polling ist der Standard und funktioniert immer. Wenn ein Server Notifications unterstützt, kann der Client einmal abonnieren und die Polling-Schleife komplett überspringen:

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

Jeder Push trägt den gesamten Task-State — Clients brauchen nie einen Follow-up-tasks/get. Falle auf Polling zurück, wenn subscriptions/listen "not supported" zurückgibt oder der Stream trennt.

Wann Tasks verwenden (und wann nicht)

Guided walkthrough1 of 6
  1. CI-Pipelines, Batch-Imports, Model-Training, Video-Encoding, große Refactors, Deployments. Wenn p99 über ~10 Sekunden liegt, willst du bereits Tasks; wenn p99 über 30 Sekunden liegt, bist du ohne sie bereits kaputt.

Migration von der 2025-11-25 experimentellen Tasks-API

Die alte tasks/create / tasks/status-Form aus der Pre-Stateless-Spec ist nicht kompatibel mit SEP-2663. Behandle es als Rewrite, nicht als Version-Bump:

Watch out
  • Alt: Client rief tasks/create explizit auf. Neu: jeder tools/call KANN als Task zurückkommen — der Client MUSS auf jeder Request ein polymorphes Result behandeln.
  • Alt: tasks/list enumerierte Tasks für eine Session. Neu: tasks/list ist absichtlich entfernt — ein stateless Server hat keine Session, nach der zu scopen, und das Auflisten über Tenants hinweg ist ein Data-Leak. Verfolge deine eigenen Task-IDs client-seitig oder in deiner Produkt-Datenbank.
  • Alt: elicitation war ein separater Server → Client-Push. Neu: elicitation faltet sich in den Task als input_required — keine unaufgeforderten Pushes nötig.
  • Alt: status war einer von {pending, running, done, error}. Neu: {working, input_required, completed, failed, cancelled}. Mappe error → failed und füge den neuen input_required-Zweig hinzu.
  • Deprecation-Uhr: die experimentelle API funktioniert mindestens bis zum 28. Juli 2027 weiter. Schreibe gegen SEP-2663 neu, lass beide Endpoints parallel laufen, schneide nach deinem eigenen Zeitplan um.

Server-Implementierungs-Checkliste

Guided walkthrough1 of 6
  1. Das CreateTaskResult ist ein Versprechen, dass der Client pollen können wird. Wenn dein DB-Write nach der HTTP-Antwort passiert, bricht ein Crash zwischen den beiden dieses Versprechen. Write-through, dann antworten.

Client-Implementierungs-Checkliste

Guided walkthrough1 of 5
  1. In dem Moment, in dem du auf Tasks opt-in gehst, kann JEDER Tool-Call als Task zurückkommen. Ein einziger ignorierter resultType: task-Zweig bedeutet still verworfene Ergebnisse.

Ein reales Beispiel: ein 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 }) };
}

Fallstricke, die die meisten Teams in Woche eins treffen

Watch out
  • 'tasks/list fehlt.' Ja — mit Absicht. Es gibt keine Session, nach der zu scopen. Verfolge Task-IDs in deiner eigenen Produkt-Datenbank.
  • 'Mein Cancel-Button lügt.' Wird er immer. Nenne ihn 'Cancellation anfordern' um oder gate die State-Änderung auf dem terminalen Ack des Servers.
  • 'Ich bekomme Ergebnisse nur, wenn ich polle.' Richtig — bis du auch notifications/tasks + subscriptions/listen implementierst. Beide Pfade, immer.
  • 'Das Python-SDK hat noch keinen Helper dafür.' Einige Tier-1-SDK-Helper stabilisieren sich noch. Du kannst das rohe JSON-RPC immer per Hand implementieren — das Wire-Format ist voll spezifiziert.
  • 'Ein Nutzer hat den Task eines anderen Nutzers gezogen, indem er die ID erraten hat.' Weil du vergessen hast, tasks/get nach Tenant zu scopen. Jeder Handler MUSS nach dem authentifizierten Principal filtern.

Quiz

Check yourself

0/4
  1. Dein Client hat io.modelcontextprotocol/tasks NICHT in sein Request-_meta aufgenommen. Das aufgerufene Tool dauert 20 Minuten. Was sollte der Server tun?
  2. Ein Nutzer drückt 'Cancel' auf einem Task 100 ms bevor er fertig wird. Dein Server verarbeitet Cancel und Completion gleichzeitig. In welchem State kann der Task legal landen?
  3. Du migrierst von der 2025-11-25 experimentellen Tasks-API. Dein alter Code ruft tasks/list auf, um eine Queue anzuzeigen. Was ist die richtige Lösung?
  4. Welches ist der RICHTIGE Grund, MRTR (SEP-2322) statt Tasks zu greifen?

Flashcards

Drücke Enter oder die Leertaste, um die Karte umzudrehen. Nutze die Pfeiltasten links und rechts, um zwischen den Karten zu wechseln.Begriff angezeigt.
1 / 8

Quellen & Weiterführendes