Das MCP-Handbuch

Kapitel 19: Tasks – Lang laufende Aufgaben & Asynchronität (Tasks-Extension, SEP-2663)

In den vorangegangenen Kapiteln haben wir gelernt, wie Tools unmittelbar auf Anfragen antworten: Ein Funktionsaufruf geht ein, der Server führt ihn synchron aus und liefert das Ergebnis in derselben Antwort zurück.

Doch was passiert, wenn eine Operation nicht 200 Millisekunden, sondern 5 Minuten oder gar zwei Stunden dauert? Denken Sie an:

  • Das Trainieren oder Finetunen eines Machine-Learning-Modells,
  • Das Durchsuchen von Millionen Logs oder Datenbankeinträgen,
  • Einen aufwendigen Docker-Build oder Integrationstest-Lauf,
  • Ein Code-Review durch ein lokales LLM, das erst das ganze Repository durchsuchen muss.

Synchrone Tool-Aufrufe führen in diesen Szenarien zu Timeouts, Verbindungsabbrüchen und einem blockierten KI-Modell. Die Antwort von MCP darauf ist Tasks: das asynchrone „Call-Now, Fetch-Later“-Muster.

Stand der Spezifikation: Tasks wurden mit SEP-1686 als experimentelles Kernfeature in 2025-11-25 eingeführt. Mit der Spezifikation 2026-07-28 sind sie aus dem Kern in eine offizielle Extension gewandert: io.modelcontextprotocol/tasks, spezifiziert in SEP-2663 und im Repository modelcontextprotocol/ext-tasks. Dabei wurde das Protokoll deutlich vereinfacht – tasks/result und tasks/list sind entfallen, die Task-Erzeugung entscheidet jetzt allein der Server. Dieses Kapitel beschreibt den Stand 2026-07-28; die Unterschiede zur Vorversion fasst Abschnitt 9 zusammen.


1. Das Problem mit synchronen Tool-Calls

Klassische MCP-Tool-Calls arbeiten strikt nach dem Request-Response-Prinzip:

Client ──tools/call──► Server (blockiert 15 Min …) ──Result──► Client

Dieses synchrone Modell scheitert bei langen Workflows an drei Problemen:

  1. Verbindungsabbrüche: HTTP-Proxies, Load Balancer und Clients kappen Verbindungen häufig nach 30 bis 60 Sekunden.
  2. Blockierter Agenten-Loop: Während der Client auf den Server wartet, ist der Chat blockiert. Der Assistent kann keine anderen Teilaufgaben parallel bearbeiten.
  3. Keine Wiederaufnahme: Stürzt der Client ab oder bricht die Verbindung, ist die Arbeit verloren – es gibt nichts, womit man später nachfragen könnte.

Tasks lösen alle drei: Der Server gibt statt des Ergebnisses einen dauerhaften Griff (taskId) zurück, über den der Client später – auch nach einem Neustart – den Stand abfragen kann.


2. Die Task-Architektur & der Lebenszyklus

MCP Task Lifecycle (Tasks extension, 2026-07-28)

Der Ablauf in 5 Schritten

  1. Capability-Signal: Der Client nimmt die Extension in die Capabilities jeder Anfrage auf (_meta → io.modelcontextprotocol/clientCapabilities). Der Server bewirbt sie in seiner Antwort auf server/discover.
  2. Tool-Aufruf (Call-Now): Der Client ruft das Tool ganz normal mit tools/call auf. Ein Task-Flag pro Aufruf gibt es nicht.
  3. Server entscheidet: Hält der Server die Arbeit für lang laufend, antwortet er sofort mit einem CreateTaskResult (resultType: "task") statt eines CallToolResult. Der Task muss zu diesem Zeitpunkt bereits dauerhaft angelegt sein.
  4. Beobachten: Der Client fragt den Stand mit tasks/get ab (im Takt von pollIntervalMs) oder abonniert notifications/tasks. Braucht der Task unterwegs Eingaben, liefert der Server sie als inputRequests; der Client antwortet mit tasks/update.
  5. Ergebnis (Fetch-Later): Erreicht der Task completed, steht das Ergebnis direkt in der tasks/get-Antwort im Feld result – genau das CallToolResult, das ein synchroner Aufruf geliefert hätte.

Wichtig ist der Perspektivwechsel gegenüber der Vorversion: Der Server ist der alleinige Entscheider. Ein Client, der die Extension deklariert, muss auf jede unterstützte Anfrage beide Antwortformen verarbeiten können. Ein Server darf einem Client, der die Extension in genau dieser Anfrage nicht deklariert hat, niemals ein CreateTaskResult schicken – kann er die Anfrage nur als Task bedienen, antwortet er mit Fehler -32021 (Missing Required Client Capability).

Derzeit unterstützt nur tools/call die Task-Ausführung; weitere Anfragetypen sind für spätere Revisionen vorgesehen.


3. Die Zustandsmaschine (Task State Machine)

                 ┌─────────────┐
      ┌─────────►│   working   │──────────────┐
      │          └──────┬──────┘              │
      │                 │                     │
      │                 ▼                     ▼
      │      ┌─────────────────┐     ┌──────────────────┐
      └──────│ input_required  │────►│  Endzustände:    │
 tasks/update└─────────────────┘     │  completed       │
                                     │  failed          │
                                     │  cancelled       │
                                     └──────────────────┘
  • working: Der Task läuft. Fortschritt wird als Freitext in statusMessage gemeldet (z. B. „Schritt 5/8: Tests laufen“). Ein numerisches Fortschrittsfeld gibt es nicht, und notifications/progress wird für Tasks ausdrücklich nicht unterstützt.
  • input_required: Der Task braucht Eingaben – etwa eine Bestätigung durch den Nutzer (Elicitation, Kapitel 21) oder eine LLM-Generierung (Sampling, Kapitel 20). Die offenen Anfragen stehen im Feld inputRequests; nach der Antwort per tasks/update kehrt der Task zu working zurück.
  • completed: Die Operation ist beendet; result enthält das Ergebnis. Auch ein Tool-Ergebnis mit isError: true ist completed.
  • failed: Ausschließlich für JSON-RPC-Fehler während der Ausführung; das Feld error enthält den Fehler. Fachliche Fehler gehören nicht hierher (siehe oben).
  • cancelled: Der Task wurde abgebrochen.

completed, failed und cancelled sind Endzustände – danach ändert sich der Task nicht mehr. Aus input_required kann ein Task direkt in einen Endzustand wechseln, etwa wenn er abgebrochen wird oder die TTL abläuft.


4. Die sofortige Antwort: `CreateTaskResult`

Statt stumm zu warten, erhält der Client sofort einen strukturierten Task-Griff:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "task",
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "status": "working",
    "statusMessage": "Docker-Build für 'auth-service:v2' gestartet.",
    "createdAt": "2026-10-09T10:30:00Z",
    "lastUpdatedAt": "2026-10-09T10:30:00Z",
    "ttlMs": 3600000,
    "pollIntervalMs": 5000
  }
}
Feld Bedeutung
resultType Diskriminator: "task" unterscheidet das CreateTaskResult von einem normalen Ergebnis ("complete").
taskId Vom Server erzeugte, nicht erratbare ID. Sie wirkt wie ein Bearer-Token für den Task-Zustand.
statusMessage Optionaler Freitext für Nutzer oder Modell.
ttlMs Lebensdauer ab Erzeugung in Millisekunden (null = unbegrenzt). Danach darf der Server den Task verwerfen.
pollIntervalMs Empfohlener Abfragetakt. Clients sollen ihn einhalten, Server dürfen schnellere Abfragen drosseln.

Der Host kann dem Modell daraufhin sofort antworten lassen:

„Ich habe den Build gestartet. Das dauert einige Minuten – soll ich in der Zwischenzeit die Dokumentation aktualisieren?“

Der Chat bleibt reaktionsfähig, während die Arbeit im Hintergrund läuft.


5. Das Task-Protokoll im Detail (RPC-Methoden)

Die Extension definiert genau drei Methoden. Alle Task-Antworten tragen resultType: "complete", weil sie die reguläre Antwortform ihrer Methode sind.

1. `tasks/get` – Status, Rückfragen und Ergebnis

Die zentrale Methode. Die Antwort enthält je nach Status zusätzliche Felder: inputRequests bei input_required, result bei completed, error bei failed.

// Request
{ "jsonrpc": "2.0", "id": 10, "method": "tasks/get",
  "params": { "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840" } }

// Response (abgeschlossen)
{
  "jsonrpc": "2.0",
  "id": 10,
  "result": {
    "resultType": "complete",
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "status": "completed",
    "createdAt": "2026-10-09T10:30:00Z",
    "lastUpdatedAt": "2026-10-09T10:33:12Z",
    "ttlMs": 3600000,
    "pollIntervalMs": 5000,
    "result": {
      "content": [{ "type": "text", "text": "Build erfolgreich. Image Digest: sha256:4a8f9..." }],
      "isError": false
    }
  }
}

2. `tasks/update` – Eingaben nachreichen

Steht der Task auf input_required, enthält tasks/get eine Map inputRequests. Ihre Werte sind gewöhnliche Server-an-Client-Anfragen (elicitation/create, sampling/createMessage oder roots/list); die Schlüssel vergibt der Server und verwendet sie während der Lebensdauer des Tasks nie zweimal.

// Ausschnitt aus tasks/get
"status": "input_required",
"inputRequests": {
  "confirm_push": {
    "method": "elicitation/create",
    "params": {
      "mode": "form",
      "message": "Image in die Produktions-Registry pushen?",
      "requestedSchema": {
        "type": "object",
        "properties": { "confirm": { "type": "boolean" } },
        "required": ["confirm"]
      }
    }
  }
}

Der Client legt die Anfrage dem Nutzer (oder Modell) vor und antwortet unter demselben Schlüssel:

{ "jsonrpc": "2.0", "id": 11, "method": "tasks/update",
  "params": {
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "inputResponses": {
      "confirm_push": { "action": "accept", "content": { "confirm": true } }
    }
  } }

Der Server quittiert mit einem leeren Ergebnis. Die Quittung ist eventually consistent: Bis tasks/get wieder working zeigt, kann etwas Zeit vergehen. Weil inputRequests bei jeder Abfrage erneut geliefert werden, solange sie offen sind, soll der Client nach Schlüssel deduplizieren – sonst sieht der Nutzer dieselbe Rückfrage mehrfach.

Kein Kanal mit höherem Vertrauen: Eine Elicitation oder ein Sampling über inputRequests unterliegt exakt denselben Regeln wie die gleiche Anfrage außerhalb eines Tasks – inklusive Freigabe durch den Menschen.

3. `tasks/cancel` – Job abbrechen

{ "jsonrpc": "2.0", "id": 12, "method": "tasks/cancel",
  "params": { "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840" } }

Der Abbruch ist kooperativ: Der Server muss quittieren, aber nicht anhalten. Der Task kann trotzdem noch completed erreichen, wenn die Arbeit schon fertig war. Der Client darf seinen Zustand zum Task sofort nach dem Senden verwerfen. notifications/cancelled darf für Tasks nicht verwendet werden.

Was es nicht (mehr) gibt

  • tasks/result – das Ergebnis steht jetzt in tasks/get. Die alte Methode blockierte, bis der Task fertig war, und erzwang dauerhafte Verbindungen.
  • tasks/list – entfallen, weil sich nicht sicher definieren lässt, wessen Tasks gelistet werden dürften. Ohne Liste kann ein Server auch nicht versehentlich die Tasks eines Aufrufers einem anderen zeigen. Konsequenz für Clients: Task-IDs selbst dauerhaft speichern, sonst sind sie nach einem Absturz verloren.

6. Polling vs. Push: `notifications/tasks`

Der Standardweg ist Polling über tasks/get im Takt von pollIntervalMs. Zusätzlich darf ein Server Statuswechsel pushen. Dazu meldet der Client sein Interesse über subscriptions/listen an und nennt die gewünschten Task-IDs:

{ "jsonrpc": "2.0", "id": 20, "method": "subscriptions/listen",
  "params": { "notifications": { "taskIds": ["786512e2-9e0d-44bd-8f29-789f320fe840"] } } }

Der Server bestätigt in notifications/subscriptions/acknowledged, für welche IDs er tatsächlich Benachrichtigungen schickt. Jede Benachrichtigung trägt den vollständigen Task-Zustand – inklusive result oder inputRequests –, sodass kein zusätzliches tasks/get nötig ist:

{
  "jsonrpc": "2.0",
  "method": "notifications/tasks",
  "params": {
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "status": "completed",
    "createdAt": "2026-10-09T10:30:00Z",
    "lastUpdatedAt": "2026-10-09T10:33:12Z",
    "ttlMs": 3600000,
    "result": { "content": [{ "type": "text", "text": "Build erfolgreich." }], "isError": false }
  }
}

Routing über Streamable HTTP

Wird tasks/get, tasks/update oder tasks/cancel über Streamable HTTP (Kapitel 16) gesendet, muss der Client den Header Mcp-Name auf die taskId setzen. So können Load Balancer Folgeanfragen an die Server-Instanz leiten, die den Task-Zustand hält.


7. Praxis-Implementierung in Go (Task-Store & TTL)

Ein solider Task-Server braucht vier Eigenschaften:

  1. Asynchroner Worker: Start in einer Goroutine, ohne den Handler zu blockieren.
  2. Kooperativer Abbruch: tasks/cancel stoppt die Arbeit über context.CancelFunc.
  3. Rückfragen: Ein Worker kann input_required setzen und auf die Antwort aus tasks/update warten.
  4. TTL / Garbage Collection: Abgelaufene Tasks werden gelöscht, um Speicherlecks zu verhindern.

Das folgende Muster zeigt die serverseitige Logik; die Typen bilden die JSON-Formen der Spezifikation direkt ab. Wie man es an das SDK anschließt, beschreibt der Abschnitt danach.

package tasks

import (
	"context"
	"maps"
	"sync"
	"time"

	"github.com/google/uuid"
)

// Task entspricht dem DetailedTask der Spezifikation (JSON-Felder 1:1).
type Task struct {
	TaskID         string         `json:"taskId"`
	Status         string         `json:"status"` // working | input_required | completed | failed | cancelled
	StatusMessage  string         `json:"statusMessage,omitempty"`
	CreatedAt      time.Time      `json:"createdAt"`
	LastUpdatedAt  time.Time      `json:"lastUpdatedAt"`
	TTLMs          int64          `json:"ttlMs"`
	PollIntervalMs int64          `json:"pollIntervalMs,omitempty"`
	InputRequests  map[string]any `json:"inputRequests,omitempty"`
	Result         any            `json:"result,omitempty"` // CallToolResult
	Error          any            `json:"error,omitempty"`  // JSON-RPC-Fehler

	cancel  context.CancelFunc
	waiting map[string]chan any // offene inputRequests -> Antwortkanal
}

type Store struct {
	mu    sync.Mutex
	tasks map[string]*Task
	ttl   time.Duration
}

func NewStore(ttl time.Duration) *Store {
	s := &Store{tasks: map[string]*Task{}, ttl: ttl}
	go s.cleanupLoop()
	return s
}

// Job ist die eigentliche Arbeit. ask stellt eine Rückfrage und blockiert bis zur Antwort.
type Job func(ctx context.Context, status func(string), ask func(key string, req any) (any, error)) (any, error)

// Start legt den Task an, BEVOR die Antwort gesendet wird (Spec: "durably created").
func (s *Store) Start(job Job) Task {
	ctx, cancel := context.WithCancel(context.Background())
	now := time.Now().UTC()
	t := &Task{
		TaskID: uuid.NewString(), // volle UUID: IDs müssen unerratbar sein
		Status: "working", CreatedAt: now, LastUpdatedAt: now,
		TTLMs: s.ttl.Milliseconds(), PollIntervalMs: 5000,
		cancel: cancel, waiting: map[string]chan any{},
	}
	s.mu.Lock()
	s.tasks[t.TaskID] = t
	snapshot := *t
	s.mu.Unlock()

	go func() {
		res, err := job(ctx, func(msg string) { s.set(t, "", msg) }, func(key string, req any) (any, error) {
			ch := make(chan any, 1)
			s.mu.Lock()
			t.waiting[key] = ch
			if t.InputRequests == nil {
				t.InputRequests = map[string]any{}
			}
			t.InputRequests[key] = req
			s.mu.Unlock()
			s.set(t, "input_required", "")
			select {
			case resp := <-ch:
				return resp, nil
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		})
		s.mu.Lock()
		defer s.mu.Unlock()
		switch {
		case ctx.Err() != nil:
			t.Status = "cancelled"
		case err != nil:
			t.Status = "failed"
			t.Error = map[string]any{"code": -32603, "message": err.Error()}
		default:
			t.Status, t.Result = "completed", res // auch isError:true ist "completed"
		}
		t.InputRequests, t.LastUpdatedAt = nil, time.Now().UTC()
	}()
	return snapshot
}

// Update verarbeitet tasks/update: unbekannte oder bereits beantwortete Schlüssel werden ignoriert.
func (s *Store) Update(id string, responses map[string]any) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tasks[id]
	if !ok {
		return false // -> JSON-RPC -32602
	}
	for key, resp := range responses {
		if ch, open := t.waiting[key]; open {
			ch <- resp
			delete(t.waiting, key)
			delete(t.InputRequests, key)
		}
	}
	if len(t.waiting) == 0 && t.Status == "input_required" {
		t.Status = "working"
	}
	t.LastUpdatedAt = time.Now().UTC()
	return true
}

// Cancel ist kooperativ: quittieren, Kontext abbrechen, Endzustand setzt der Worker.
func (s *Store) Cancel(id string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if t, ok := s.tasks[id]; ok {
		t.cancel()
		return true
	}
	return false
}

// Get liefert eine Kopie für tasks/get (bzw. notifications/tasks).
func (s *Store) Get(id string) (Task, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, false
	}
	c := *t
	c.InputRequests = maps.Clone(t.InputRequests) // Kopie, damit Serialisierung außerhalb des Locks sicher ist
	return c, true
}

func (s *Store) set(t *Task, status, msg string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if status != "" {
		t.Status = status
	}
	if msg != "" {
		t.StatusMessage = msg
	}
	t.LastUpdatedAt = time.Now().UTC()
}

func (s *Store) cleanupLoop() {
	for range time.Tick(10 * time.Minute) {
		s.mu.Lock()
		for id, t := range s.tasks {
			if time.Since(t.CreatedAt) > s.ttl {
				t.cancel()
				delete(s.tasks, id)
			}
		}
		s.mu.Unlock()
	}
}

Im Tool-Handler ruft der Server store.Start(...) auf und gibt den zurückgelieferten Task mit resultType: "task" als Antwort auf tools/call zurück – aber nur, wenn die Anfrage die Extension in _meta deklariert hat. Andernfalls arbeitet er synchron oder antwortet mit -32021.

Anschluss an das go-sdk

Das offizielle go-sdk (Stand v1.8.0) bringt keine fertige Task-Implementierung mit – weder die experimentelle Fassung von 2025-11-25 noch die Extension. Der Stand der Arbeit ist in Issue #626 nachzulesen: Die Capability-Aushandlung und der Task-Typ liegen als Pull Requests vor; offen ist vor allem die Designfrage, wo das SDK die Lebensdauer eines Tasks verwalten soll. Die Referenzimplementierung ist derzeit das TypeScript-Paket im Repository ext-tasks.

Das SDK bietet aber alle Erweiterungspunkte, die man für die Extension braucht:

Baustein der Extension Erweiterungspunkt im go-sdk
Extension in server/discover bewerben ServerCapabilities.AddExtension
tools/call abfangen und CreateTaskResult liefern Server.AddReceivingMiddleware
tasks/get, tasks/update, tasks/cancel mcp.AddReceivingCustomMethod
inputRequests / inputResponses mcp.InputRequestMap, mcp.InputResponseMap (MRTR)

Genau darauf baut das Paket mcptasks aus dem mcp-tester auf (github.com/hmsoft0815/mlc_mcptester/pkg/mcptasks). Damit wird ein bestehender Server mit wenigen Zeilen task-fähig – die Tool-Handler selbst bleiben unverändert:

caps := &mcp.ServerCapabilities{Tools: &mcp.ToolCapabilities{}}
mcptasks.Declare(caps) // Extension in server/discover bewerben
s := mcp.NewServer(&mcp.Implementation{Name: "build-server", Version: "1.0.0"},
	&mcp.ServerOptions{Capabilities: caps})
mcp.AddTool(s, buildTool, buildHandler) // ganz normaler Handler

// Diese Tools laufen als Task, wenn der Client die Extension deklariert – sonst synchron.
if err := mcptasks.Enable(s, mcptasks.NewStore(), "docker_build"); err != nil {
	log.Fatal(err)
}

Braucht ein Handler unterwegs eine Eingabe, ruft er mcptasks.RequestInput(ctx, mcp.InputRequestMap{...}) auf; der Task wechselt dann auf input_required, bis der Client per tasks/update antwortet. Mit mcptasks.IsTask(ctx) erkennt der Handler, ob er als Task läuft, und kann außerhalb eines Tasks auf den synchronen MRTR-Weg ausweichen. Eine Einschränkung: mcptasks setzt die statusMessage selbst („in progress“, „completed“); eigene Fortschrittstexte aus dem Handler heraus unterstützt das Paket derzeit nicht – dafür braucht man einen eigenen Store wie oben.

Client-seitig ist die Lage schwieriger: Der go-sdk-Client erwartet auf tools/call fest ein CallToolResult und verwirft die Felder eines CreateTaskResult. Wer einen Go-Client für Tasks braucht, muss die Anfragen derzeit an der typisierten API vorbei senden.

Testen mit `mcp-tester`

Ob ein Server die Extension korrekt umsetzt, prüft der mcp-tester (Kapitel 14):

# Tool als Task aufrufen und bis zum Ende verfolgen (ab v1.7.0)
mcp-tester call docker_build --task -c ./build-server --args '{"image":"auth-service"}'

# Server gegen die Extension prüfen: Fehlercodes, Task-Handle, ID-Entropie,
# dauerhafte Anlage, jedes tasks/get, Statusübergänge (ab v1.8.0)
mcp-tester tasks -c ./build-server --tool docker_build --args '{"image":"auth-service"}'

In Testskripten stehen dafür call_task, start_task, wait_task, get_task, cancel_task und assert_task_status bereit.


8. Tasks als Hülle für Sub-Agenten

Liest man die Zustandsmaschine noch einmal mit etwas Abstand, beschreibt sie mehr als einen Docker-Build. Sie beschreibt genau das, was ein Sub-Agent braucht: einen delegierten Auftrag mit eigenem Lebenszyklus, den der Haupt-Agent abgibt, beobachtet, bei Rückfragen bedient und am Ende auswertet.

Was ein Sub-Agent braucht Was Tasks dafür liefern
Auftrag abgeben, ohne zu blockieren tools/call → sofortiges CreateTaskResult
Zwischenstand sehen statusMessage über tasks/get oder notifications/tasks
Rückfrage an Mensch oder Modell input_required + inputRequests (Elicitation, Sampling)
Abbrechen tasks/cancel
Ergebnis übergeben result im abgeschlossenen Task
Absturz des Hosts überleben dauerhafte taskId, TTL
Mehrere Agenten gleichzeitig mehrere Tasks, je ein Griff

Tasks allein sind aber noch kein Sub-Agent. Sie sind die Hülle – das Denken findet woanders statt: in einer Agenten-Schleife im Server, die ein eigenes LLM anspricht (etwa ein lokales Qwen) und dabei eigene Werkzeuge benutzt. Ein typisches Beispiel ist ein Review-Server, der Code des Haupt-Agenten auf Duplikate und Hausstandards prüft. Wie man so einen Server baut und wann sich das gegenüber den eingebauten Sub-Agenten des Hosts lohnt, zeigt Kapitel 20, Abschnitt „Der Server als Sub-Agent“.


9. Migration von `2025-11-25`

2025-11-25 (experimentell, SEP-1686) 2026-07-28 (Extension, SEP-2663)
Capability tasks.requests.tools.call + Tool-Feld execution.taskSupport Extension io.modelcontextprotocol/tasks in den Per-Request-Capabilities
Client setzt task-Parameter pro Aufruf Server entscheidet allein; kein Flag pro Aufruf
tasks/result (blockierend) Ergebnis inline in tasks/get
tasks/list entfallen – Task-IDs selbst speichern
Rückfragen über Nebenkanal während tasks/result inputRequests in tasks/get, Antwort per tasks/update
notifications/tasks/status notifications/tasks über subscriptions/listen
Client-seitige Tasks für Sampling/Elicitation entfallen (SEP-2260: keine unaufgeforderten Server-Anfragen)
ttl ttlMs, dazu pollIntervalMs

Für bestehende Clients gilt: Wer nach außen ein festes CallToolResult zurückgibt, kann das Polling intern abwickeln und nur das Endergebnis zeigen – die öffentliche Schnittstelle muss sich nicht ändern.


10. Häufige Fehler & Best Practices

Fehler Folge Lösung
Kurze oder fortlaufende Task-IDs Fremde Clients können Tasks erraten und Ergebnisse abgreifen IDs mit ausreichend Entropie (volle UUID, Krypto-Zufall); bei jeder tasks/*-Anfrage Authentifizierung und Berechtigung prüfen
CreateTaskResult ohne Capability-Prüfung Ältere Clients erhalten eine Antwort, die sie nicht verstehen Extension pro Anfrage in _meta prüfen, sonst synchron arbeiten oder -32021
Fachfehler als failed Client hält einen Tool-Fehler für einen Protokollfehler failed nur für JSON-RPC-Fehler; Tool-Fehler sind completed mit isError: true
Keine TTL / Cleanup Server verbraucht mit jedem Tag mehr RAM Feste TTL (z. B. 1–24 h) und Aufräum-Schleife
Kein Abbruch an den Worker weitergereicht Gekündigter Job läuft weiter und frisst CPU context.WithCancel() nutzen und in Schleifen auf ctx.Done() prüfen
pollIntervalMs ignoriert Hunderte Anfragen pro Minute Takt einhalten oder notifications/tasks abonnieren
Rückfragen nicht dedupliziert Nutzer sieht dieselbe Bestätigung bei jedem Poll Bereits gezeigte inputRequests-Schlüssel merken
Task-IDs nur im RAM des Clients Nach Absturz sind laufende Tasks unauffindbar (es gibt kein tasks/list) IDs dauerhaft speichern

Fazit

Tasks verwandeln MCP von einem synchronen RPC-Protokoll in ein asynchrones, wiederaufnehmbares Job-System. Mit der Extension 2026-07-28 ist das Modell schlanker geworden: drei Methoden, der Server entscheidet, Rückfragen und Ergebnis laufen über tasks/get. Für Entwickler von MCP-Servern bedeutet das: keine Timeouts mehr, keine blockierten Chats – und eine tragfähige Grundlage, um Server als eigenständige Sub-Agenten in Agenten-Workflows einzubinden.

← Kapitel 18: Notifications | Inhaltsverzeichnis | Nächstes Kapitel: Agentische Server & Sampling →


Copyright Michael Lechner – 2026-10-09 (Angleichung an die Tasks-Extension SEP-2663 / Spezifikation 2026-07-28, Abschnitt „Tasks als Hülle für Sub-Agenten“)

Lizenz: CC BY-NC 4.0