The MCP Handbook

Chapter 19: Tasks – Long-Running Operations & Asynchrony (Tasks Extension, SEP-2663)

In the previous chapters we saw how tools respond to requests immediately: a function call arrives, the server executes it synchronously, and returns the result in the same response.

But what happens when an operation takes not 200 milliseconds but 5 minutes, or even two hours? Consider:

  • Training or fine-tuning a machine learning model,
  • Searching through millions of log lines or database records,
  • An expensive Docker build or integration test run,
  • A code review by a local LLM that first has to search the entire repository.

In these scenarios, synchronous tool calls lead to timeouts, dropped connections, and a blocked AI model. MCP's answer to this is Tasks: the asynchronous "Call-Now, Fetch-Later" pattern.

Specification status: Tasks were introduced with SEP-1686 as an experimental core feature in 2025-11-25. With specification 2026-07-28, they moved out of the core into an official extension: io.modelcontextprotocol/tasks, specified in SEP-2663 and in the repository modelcontextprotocol/ext-tasks. Along the way, the protocol was simplified considerably – tasks/result and tasks/list were dropped, and the decision to create a task now rests solely with the server. This chapter describes the 2026-07-28 state; Section 9 summarizes the differences from the previous version.


1. The Problem with Synchronous Tool Calls

Classic MCP tool calls follow a strict request-response model:

Client ──tools/call──► Server (blocks for 15 min …) ──result──► Client

This synchronous model breaks down on long-running workflows because of three problems:

  1. Dropped connections: HTTP proxies, load balancers, and clients often cut connections after 30 to 60 seconds.
  2. Blocked agent loop: While the client waits for the server, the chat is blocked. The assistant cannot work on other subtasks in parallel.
  3. No resumption: If the client crashes or the connection drops, the work is lost – there is nothing to follow up on later.

Tasks solve all three: instead of the result, the server returns a durable handle (taskId) that the client can use later – even after a restart – to query the current state.


2. Task Architecture & Lifecycle

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

The Flow in 5 Steps

  1. Capability signal: The client includes the extension in the capabilities of every request (_meta → io.modelcontextprotocol/clientCapabilities). The server advertises it in its response to server/discover.
  2. Tool call (Call-Now): The client calls the tool as usual with tools/call. There is no per-call task flag.
  3. The server decides: If the server considers the work long-running, it responds immediately with a CreateTaskResult (resultType: "task") instead of a CallToolResult. At that point, the task must already be durably created.
  4. Observe: The client queries the state with tasks/get (at the pollIntervalMs rate) or subscribes to notifications/tasks. If the task needs input along the way, the server delivers it as inputRequests; the client answers with tasks/update.
  5. Result (Fetch-Later): Once the task reaches completed, the result is directly in the tasks/get response in the result field – exactly the CallToolResult a synchronous call would have returned.

The key change in perspective compared to the previous version: the server is the sole decision-maker. A client that declares the extension must be able to handle both response forms for every supported request. A server must never send a CreateTaskResult to a client that did not declare the extension in that very request – if it can only serve the request as a task, it responds with error -32021 (Missing Required Client Capability).

Currently only tools/call supports task execution; other request types are planned for later revisions.


3. The Task State Machine

                 ┌─────────────┐
      ┌─────────►│   working   │──────────────┐
      │          └──────┬──────┘              │
      │                 │                     │
      │                 ▼                     ▼
      │      ┌─────────────────┐     ┌──────────────────┐
      └──────│ input_required  │────►│  Terminal states:│
 tasks/update└─────────────────┘     │  completed       │
                                     │  failed          │
                                     │  cancelled       │
                                     └──────────────────┘
  • working: The task is running. Progress is reported as free text in statusMessage (e.g. "Step 5/8: running tests"). There is no numeric progress field, and notifications/progress is explicitly not supported for tasks.
  • input_required: The task needs input – for example a confirmation from the user (elicitation, Chapter 21) or an LLM generation (sampling, Chapter 20). The open requests are in the inputRequests field; after the response via tasks/update, the task returns to working.
  • completed: The operation has finished; result contains the result. A tool result with isError: true is also completed.
  • failed: Exclusively for JSON-RPC errors during execution; the error field contains the error. Domain errors do not belong here (see above).
  • cancelled: The task was cancelled.

completed, failed, and cancelled are terminal states – after that, the task no longer changes. A task can move from input_required directly into a terminal state, for example when it is cancelled or its TTL expires.


4. The Immediate Response: `CreateTaskResult`

Instead of waiting silently, the client immediately receives a structured task handle:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "task",
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "status": "working",
    "statusMessage": "Docker build for 'auth-service:v2' started.",
    "createdAt": "2026-10-09T10:30:00Z",
    "lastUpdatedAt": "2026-10-09T10:30:00Z",
    "ttlMs": 3600000,
    "pollIntervalMs": 5000
  }
}
Field Meaning
resultType Discriminator: "task" distinguishes the CreateTaskResult from a normal result ("complete").
taskId Server-generated, unguessable ID. It acts like a bearer token for the task state.
statusMessage Optional free text for the user or the model.
ttlMs Lifetime from creation in milliseconds (null = unlimited). After that, the server may discard the task.
pollIntervalMs Recommended polling interval. Clients should respect it; servers may throttle faster polling.

The host can then let the model respond right away:

"I've started the build. It will take a few minutes – shall I update the documentation in the meantime?"

The chat stays responsive while the work runs in the background.


5. The Task Protocol in Detail (RPC Methods)

The extension defines exactly three methods. All task responses carry resultType: "complete", because they are the regular response form of their method.

1. `tasks/get` – Status, Input Requests, and Result

The central method. Depending on the status, the response contains additional fields: inputRequests for input_required, result for completed, error for failed.

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

// Response (completed)
{
  "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 succeeded. Image digest: sha256:4a8f9..." }],
      "isError": false
    }
  }
}

2. `tasks/update` – Supplying Input

When the task is in input_required, tasks/get contains an inputRequests map. Its values are ordinary server-to-client requests (elicitation/create, sampling/createMessage, or roots/list); the keys are assigned by the server and never reused during the task's lifetime.

// Excerpt from tasks/get
"status": "input_required",
"inputRequests": {
  "confirm_push": {
    "method": "elicitation/create",
    "params": {
      "mode": "form",
      "message": "Push the image to the production registry?",
      "requestedSchema": {
        "type": "object",
        "properties": { "confirm": { "type": "boolean" } },
        "required": ["confirm"]
      }
    }
  }
}

The client presents the request to the user (or model) and responds under the same key:

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

The server acknowledges with an empty result. The acknowledgement is eventually consistent: it may take a moment before tasks/get shows working again. Because inputRequests are returned on every poll for as long as they are open, the client should deduplicate by key – otherwise the user sees the same prompt several times.

No higher-trust channel: An elicitation or sampling request via inputRequests is subject to exactly the same rules as the same request outside a task – including human approval.

3. `tasks/cancel` – Cancelling a Job

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

Cancellation is cooperative: the server must acknowledge it but is not required to stop. The task can still reach completed if the work had already finished. The client may discard its state for the task immediately after sending. notifications/cancelled must not be used for tasks.

What No Longer Exists

  • tasks/result – the result is now in tasks/get. The old method blocked until the task was finished and forced long-lived connections.
  • tasks/list – dropped because there is no safe way to define whose tasks may be listed. Without a list, a server also cannot accidentally show one caller's tasks to another. Consequence for clients: persist task IDs yourself, otherwise they are lost after a crash.

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

The default approach is polling via tasks/get at the pollIntervalMs rate. In addition, a server may push status changes. To receive them, the client registers its interest via subscriptions/listen, naming the task IDs it wants:

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

The server confirms in notifications/subscriptions/acknowledged which IDs it will actually send notifications for. Each notification carries the complete task state – including result or inputRequests – so no additional tasks/get is needed:

{
  "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 succeeded." }], "isError": false }
  }
}

Routing over Streamable HTTP

When tasks/get, tasks/update, or tasks/cancel is sent over Streamable HTTP (Chapter 16), the client must set the Mcp-Name header to the taskId. This lets load balancers route follow-up requests to the server instance that holds the task state.


7. Practical Implementation in Go (Task Store & TTL)

A solid task server needs four properties:

  1. Asynchronous worker: Starts in a goroutine without blocking the handler.
  2. Cooperative cancellation: tasks/cancel stops the work via context.CancelFunc.
  3. Input requests: A worker can set input_required and wait for the response from tasks/update.
  4. TTL / garbage collection: Expired tasks are deleted to prevent memory leaks.

The following pattern shows the server-side logic; the types map directly to the JSON shapes of the specification. How to connect it to the SDK is covered in the section after it.

package tasks

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

	"github.com/google/uuid"
)

// Task corresponds to the specification's DetailedTask (JSON fields 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 error

	cancel  context.CancelFunc
	waiting map[string]chan any // open inputRequests -> response channel
}

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 is the actual work. ask issues an input request and blocks until the response arrives.
type Job func(ctx context.Context, status func(string), ask func(key string, req any) (any, error)) (any, error)

// Start creates the task BEFORE the response is sent (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(), // full UUID: IDs must be unguessable
		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 // isError:true is "completed" too
		}
		t.InputRequests, t.LastUpdatedAt = nil, time.Now().UTC()
	}()
	return snapshot
}

// Update handles tasks/update: unknown or already answered keys are ignored.
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 is cooperative: acknowledge, cancel the context; the worker sets the terminal state.
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 returns a copy for tasks/get (or 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) // copy so that serialization outside the lock is safe
	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()
	}
}

In the tool handler, the server calls store.Start(...) and returns the resulting task with resultType: "task" as the response to tools/call – but only if the request declared the extension in _meta. Otherwise it works synchronously or responds with -32021.

Connecting to the go-sdk

The official go-sdk (as of v1.8.0) ships no ready-made task implementation – neither the experimental 2025-11-25 version nor the extension. The state of the work is tracked in issue #626: capability negotiation and the Task type exist as pull requests; the main open point is the design question of where the SDK should manage a task's lifetime. The reference implementation is currently the TypeScript package in the ext-tasks repository.

The SDK does, however, offer every extension point the extension needs:

Part of the extension Extension point in the go-sdk
Advertise the extension in server/discover ServerCapabilities.AddExtension
Intercept tools/call and return a CreateTaskResult Server.AddReceivingMiddleware
tasks/get, tasks/update, tasks/cancel mcp.AddReceivingCustomMethod
inputRequests / inputResponses mcp.InputRequestMap, mcp.InputResponseMap (MRTR)

That is exactly what the mcptasks package from the mcp-tester builds on (github.com/hmsoft0815/mlc_mcptester/pkg/mcptasks). With it, an existing server becomes task-capable in a few lines – the tool handlers themselves stay unchanged:

caps := &mcp.ServerCapabilities{Tools: &mcp.ToolCapabilities{}}
mcptasks.Declare(caps) // advertise the extension in server/discover
s := mcp.NewServer(&mcp.Implementation{Name: "build-server", Version: "1.0.0"},
	&mcp.ServerOptions{Capabilities: caps})
mcp.AddTool(s, buildTool, buildHandler) // an ordinary handler

// These tools run as a task when the client declares the extension – synchronously otherwise.
if err := mcptasks.Enable(s, mcptasks.NewStore(), "docker_build"); err != nil {
	log.Fatal(err)
}

If a handler needs input along the way, it calls mcptasks.RequestInput(ctx, mcp.InputRequestMap{...}); the task then switches to input_required until the client answers via tasks/update. With mcptasks.IsTask(ctx) the handler can tell whether it runs as a task and fall back to the synchronous MRTR path otherwise. One limitation: mcptasks sets the statusMessage itself ("in progress", "completed"); custom progress texts from within the handler are not supported by the package at the moment – for that you need your own store as shown above.

On the client side things are harder: the go-sdk client expects a fixed CallToolResult from tools/call and drops the fields of a CreateTaskResult. If you need a Go client for tasks, you currently have to send the requests around the typed API.

Testing with `mcp-tester`

Whether a server implements the extension correctly is checked by mcp-tester (Chapter 14):

# Call a tool as a task and follow it to the end (since v1.7.0)
mcp-tester call docker_build --task -c ./build-server --args '{"image":"auth-service"}'

# Check the server against the extension: error codes, task handle, ID entropy,
# durable creation, every tasks/get, status transitions (since v1.8.0)
mcp-tester tasks -c ./build-server --tool docker_build --args '{"image":"auth-service"}'

Test scripts offer call_task, start_task, wait_task, get_task, cancel_task and assert_task_status for this.


8. Tasks as a Shell for Sub-Agents

If you look at the state machine again with a little distance, it describes more than a Docker build. It describes exactly what a sub-agent needs: a delegated assignment with its own lifecycle, which the main agent hands off, observes, serves when questions come up, and evaluates at the end.

What a sub-agent needs What Tasks provide for it
Hand off an assignment without blocking tools/call → immediate CreateTaskResult
See intermediate status statusMessage via tasks/get or notifications/tasks
Ask a human or a model input_required + inputRequests (elicitation, sampling)
Cancel tasks/cancel
Hand over the result result in the completed task
Survive a host crash durable taskId, TTL
Several agents at once several tasks, one handle each

Tasks alone are not yet a sub-agent, though. They are the shell – the thinking happens elsewhere: in an agent loop inside the server that talks to its own LLM (for example a local Qwen) and uses its own tools along the way. A typical example is a review server that checks the main agent's code for duplicates and in-house standards. How to build such a server, and when it pays off compared to the host's built-in sub-agents, is shown in Chapter 20, section "The Server as a Sub-Agent".


9. Migration from `2025-11-25`

2025-11-25 (experimental, SEP-1686) 2026-07-28 (extension, SEP-2663)
Capability tasks.requests.tools.call + tool field execution.taskSupport Extension io.modelcontextprotocol/tasks in the per-request capabilities
Client sets a task parameter per call Server decides alone; no per-call flag
tasks/result (blocking) Result inline in tasks/get
tasks/list Dropped – persist task IDs yourself
Input requests over a side channel during tasks/result inputRequests in tasks/get, response via tasks/update
notifications/tasks/status notifications/tasks via subscriptions/listen
Client-side tasks for sampling/elicitation Dropped (SEP-2260: no unsolicited server requests)
ttl ttlMs, plus pollIntervalMs

For existing clients: if you return a fixed CallToolResult to the outside world, you can handle polling internally and expose only the final result – the public interface does not have to change.


10. Common Mistakes & Best Practices

Mistake Consequence Solution
Short or sequential task IDs Other clients can guess tasks and grab their results IDs with sufficient entropy (full UUID, cryptographic randomness); check authentication and authorization on every tasks/* request
CreateTaskResult without a capability check Older clients receive a response they do not understand Check the extension per request in _meta; otherwise work synchronously or return -32021
Domain errors as failed The client mistakes a tool error for a protocol error failed only for JSON-RPC errors; tool errors are completed with isError: true
No TTL / cleanup The server consumes more RAM every day Fixed TTL (e.g. 1–24 h) and a cleanup loop
Cancellation not propagated to the worker The cancelled job keeps running and eats CPU Use context.WithCancel() and check ctx.Done() in loops
pollIntervalMs ignored Hundreds of requests per minute Respect the interval or subscribe to notifications/tasks
Input requests not deduplicated The user sees the same confirmation on every poll Remember inputRequests keys that have already been shown
Task IDs only in the client's RAM After a crash, running tasks cannot be found (there is no tasks/list) Persist IDs durably

Conclusion

Tasks turn MCP from a synchronous RPC protocol into an asynchronous, resumable job system. With the 2026-07-28 extension, the model has become leaner: three methods, the server decides, and input requests and results flow through tasks/get. For MCP server developers this means: no more timeouts, no more blocked chats – and a solid foundation for integrating servers as independent sub-agents into agent workflows.

← Chapter 18: Extensions - Notifications | Table of Contents | Next Chapter: Agentic Servers & Sampling →


Copyright Michael Lechner – 2026-10-09 (Aligned with the Tasks extension SEP-2663 / specification 2026-07-28, new section "Tasks as a Shell for Sub-Agents")

Licence: CC BY-NC 4.0