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 specification2026-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/resultandtasks/listwere dropped, and the decision to create a task now rests solely with the server. This chapter describes the2026-07-28state; 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──► ClientThis synchronous model breaks down on long-running workflows because of three problems:
- Dropped connections: HTTP proxies, load balancers, and clients often cut connections after 30 to 60 seconds.
- Blocked agent loop: While the client waits for the server, the chat is blocked. The assistant cannot work on other subtasks in parallel.
- 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

The Flow in 5 Steps
- Capability signal: The client includes the extension in the capabilities of every request (
_meta→io.modelcontextprotocol/clientCapabilities). The server advertises it in its response toserver/discover. - Tool call (Call-Now): The client calls the tool as usual with
tools/call. There is no per-call task flag. - The server decides: If the server considers the work long-running, it responds immediately with a
CreateTaskResult(resultType: "task") instead of aCallToolResult. At that point, the task must already be durably created. - Observe: The client queries the state with
tasks/get(at thepollIntervalMsrate) or subscribes tonotifications/tasks. If the task needs input along the way, the server delivers it asinputRequests; the client answers withtasks/update. - Result (Fetch-Later): Once the task reaches
completed, the result is directly in thetasks/getresponse in theresultfield – exactly theCallToolResulta 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 instatusMessage(e.g. "Step 5/8: running tests"). There is no numeric progress field, andnotifications/progressis 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 theinputRequestsfield; after the response viatasks/update, the task returns toworking.completed: The operation has finished;resultcontains the result. A tool result withisError: trueis alsocompleted.failed: Exclusively for JSON-RPC errors during execution; theerrorfield 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
inputRequestsis 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 intasks/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:
- Asynchronous worker: Starts in a goroutine without blocking the handler.
- Cooperative cancellation:
tasks/cancelstops the work viacontext.CancelFunc. - Input requests: A worker can set
input_requiredand wait for the response fromtasks/update. - 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")