All Articles
CategoryAI
Reading Time
14 min read
Published
2026-08-14
Word Count
3,321words

Grab a coffee — this one is a deep dive!

MCP Tasks: Structuring Tool Calls That Take Minutes

Summary

What is MCP Tasks and when should you use it? Make long-running tool calls resilient to timeouts and crashes with taskId, ttlMs, pollIntervalMs, and the input_required flow.

  • MCP Tasks is the official io.modelcontextprotocol/tasks extension that lets the server return a durable taskId instead of an immediate result.
  • A task is in one of working, input_required, completed, failed, or cancelled; the last three are terminal.
  • CreateTaskResult carries taskId, ttlMs (lifetime), and pollIntervalMs (recommended poll interval).
  • input_required enables human approval, notifications/tasks escapes polling, and tasks/cancel gives cooperative cancellation.
MCP Tasks: Structuring Tool Calls That Take Minutes

When an MCP tool call takes minutes to finish, the standard synchronous JSON-RPC model puts you in a bind: the connection times out, the job is left half-done if the client crashes, and no signal about progress ever arrives. That's where MCP Tasks comes in — the official io.modelcontextprotocol/tasks extension solves all three problems at once by having the server return a durable taskId instead of an immediate result. This guide walks through the protocol's real mechanics with code examples: the lifecycle, the input_required approval flow, and notification-based updates instead of polling.

💡 Pro Tip: If the server returned a pollIntervalMs, don't ignore it — the spec expects clients to honor this interval at the "SHOULD" level; aggressive polling wears the server down for nothing.

Table of Contents

Why Blocking Doesn't Work

MCP tool calls default to a synchronous JSON-RPC request/response loop: the client asks, the server keeps the connection open, and returns the moment the result is ready. For CI pipelines, batch data processing, or tools wrapping an external job system, this model breaks at three distinct points.

Transport timeouts

The official documentation is clear on this: many clients and transport middleware layers impose timeouts that make connections lasting more than a few seconds impractical. Relying on the transport layer to keep a connection open for minutes is a fragile design — intermediate proxies, load balancers, and client-side HTTP clients aren't under your control.

Crash resilience

While a task runs, the client can lose the connection, a tab can close, a process can restart. In the Tasks extension, taskId is a durable handle — the client can resume polling right where it left off, whether in the same session or over a brand-new connection. The work keeps running server-side, independent of the client's presence.

Progress visibility

In the synchronous model, the client gets no answer at all to "is it still running, or did it get stuck?" Tasks provides this visibility at the protocol level via the task's status metadata (working, input_required, completed, failed, cancelled) plus optional status messages. The schema's statusMessage field is optional and can carry a human-readable description per status: progress for working, what's blocking things for input_required, diagnostics for failed. It's also the field that reports batch-job progress — you can show the client a clear status like "still running" or "waiting on your approval."

Task Lifecycle and Statuses

From the moment a task is created, it sits in one of five statuses. Three are terminal: once reached, the status never changes again.

Status
Meaning
Terminal?
working
The server is still processing the task
No
input_required
The task is waiting on additional input from the client (e.g. elicitation approval)
No
completed
The task finished successfully, the result is ready
Yes
failed
The task ended with an error
Yes
cancelled
The task was cancelled at the client's request
Yes

Once a task reaches one of the terminal statuses, its status never changes again — as long as the TTL hasn't expired, the client sees the same terminal status and the same result even if it queries again with tasks/get. This simplifies the polling logic: the moment the client sees a terminal status, it can safely end the polling loop.

Capability Negotiation: Client and Server Side

Tasks uses MCP's standard extension negotiation mechanism — no protocol-specific "task mode" flag was invented. The extension's identity is the string io.modelcontextprotocol/tasks, and both sides must announce it separately.

Client side

The client includes the io.modelcontextprotocol/tasks extension among the per-request capabilities on every request it sends. This signals "I'm task-aware, and I'll understand it if a taskId comes back instead of a result."

Server side

The server lists the same identifier in its own server/discover response. On the schema side, this capability's body is intentionally empty:

typescript
1type TasksExtensionCapability = Record<string, never>;
2// Denotes support for an empty object; no extension-specific
3// settings are currently defined.

Task support requires explicit opt-in from both sides — it isn't enough for only the client or only the server to announce it; both must announce the same identifier.

  • Extension Identifier: io.modelcontextprotocol/tasks — the same string on both the client and server side.
  • Per-request capability: The client announces task support per request rather than through a general handshake.
  • server/discover: The standard discovery response where the server announces its extension support.

CreateTaskResult, ttlMs and pollIntervalMs

When the server decides to process a request asynchronously, it returns a CreateTaskResult carrying resultType: "task" instead of the standard result shape. The schema expresses this as a Result & Task union — it carries both the general MCP Result fields and the task-specific ones (taskId, status, statusMessage, createdAt, lastUpdatedAt, ttlMs, pollIntervalMs) at once. createdAt and lastUpdatedAt are required schema fields, given as ISO 8601 timestamps.

json
1{
2 "jsonrpc": "2.0",
3 "id": 42,
4 "result": {
5 "resultType": "task",
6 "taskId": "task_8f2a1c",
7 "status": "working",
8 "createdAt": "2026-08-12T09:14:02Z",
9 "lastUpdatedAt": "2026-08-12T09:14:02Z",
10 "ttlMs": 1800000,
11 "pollIntervalMs": 5000
12 }
13}

ttlMs — the task's lifetime

The ttlMs field is the task's lifetime in milliseconds, counted from creation. null means unlimited; a number means the server may discard the task once that duration elapses. In the example above, 1800000 ms — a 30-minute TTL — is defined; querying the same taskId after that period may return nothing.

pollIntervalMs is the polling interval the server recommends. The spec language is "SHOULD": clients are expected to honor it so as not to wear the server down needlessly, but it isn't mandatory. The field is optional — without it, the client picks its own reasonable interval.

  • taskId: The durable identifier that uniquely identifies a task, used in tasks/get and tasks/cancel calls.
  • Durable creation: The task must already have been durably created on the server side before the response is sent to the client — by the time the response arrives, the task already "exists."

Human Approval Flow via input_required

Some tasks need human approval midway — an elicitation, a "do you approve this?" question, or a missing parameter. The task then moves to input_required and carries the pending requests in the inputRequests map. Keys are arbitrary identifiers used to match a request to its response. The values, though, aren't a shortened summary but full JSON-RPC request objects: the schema defines InputRequest as a union of CreateMessageRequest | ListRootsRequest | ElicitRequest, so they arrive with method and params fields.

json
1{
2 "jsonrpc": "2.0",
3 "id": 43,
4 "result": {
5 "resultType": "complete",
6 "taskId": "task_8f2a1c",
7 "status": "input_required",
8 "createdAt": "2026-08-12T09:14:02Z",
9 "lastUpdatedAt": "2026-08-12T09:16:41Z",
10 "ttlMs": 1800000,
11 "inputRequests": {
12 "confirm_deploy": {
13 "method": "elicitation/create",
14 "params": {
15 "mode": "form",
16 "message": "Do you approve the prod deploy?",
17 "requestedSchema": {
18 "type": "object",
19 "properties": {
20 "approve": { "type": "boolean" }
21 },
22 "required": ["approve"]
23 }
24 }
25 }
26 }
27 }
28}

The resultType of a tasks/get response must be "complete" per the schema — "task" only belongs to the CreateTaskResult returned when the task is first created.

The client responds directly with a tasks/update call, without opening a second connection or getting an unexpected message from the server:

json
1{
2 "jsonrpc": "2.0",
3 "id": 44,
4 "method": "tasks/update",
5 "params": {
6 "taskId": "task_8f2a1c",
7 "inputResponses": {
8 "confirm_deploy": {
9 "action": "accept",
10 "content": { "approve": true }
11 }
12 }
13 }
14}

The response side is symmetric: InputResponse is a union of CreateMessageResult | ListRootsResult | ElicitResult, so an elicitation response comes back with action and content. The schema rule is explicit: every inputResponses key must correspond to a key currently outstanding in inputRequests. This lets you build human-in-the-loop flows without breaking the polling loop — a natural fit for deploy approval, permission to run a risky migration, or clarifying an ambiguous parameter.

  • inputRequests: The map of server-to-client requests that must be satisfied while the task is running.
  • tasks/update: The method the client uses to respond to pending input requests.

Escaping Polling with notifications/tasks

Polling is the default behavior of the Tasks extension — the client waits pollIntervalMs, queries with tasks/get, waits again. But the server can also actively push status changes if it wants to. Notification parameters are defined in the schema as NotificationParams & DetailedTask: every notification carries a complete DetailedTask, so a completed notification also includes the result field, and no extra tasks/get round trip is needed.

json
1{
2 "jsonrpc": "2.0",
3 "method": "notifications/tasks",
4 "params": {
5 "taskId": "task_8f2a1c",
6 "status": "completed",
7 "createdAt": "2026-08-12T09:14:02Z",
8 "lastUpdatedAt": "2026-08-12T09:21:55Z",
9 "ttlMs": 1800000,
10 "result": {
11 "content": [{ "type": "text", "text": "Deploy completed." }]
12 }
13 }
14}

To receive these notifications, the client must subscribe via the subscriptions/listen mechanism; the taskIds field in the TaskSubscriptionNotifications schema specifies which task IDs it wants notifications for. There's a matching piece on the server side: TaskSubscriptionAcknowledgedNotifications.taskIds reports which task IDs the server has agreed to notify on. Polling stays the default; if the server supports notifications, the client can rely on them instead. In other words, notifications don't replace polling, they make it optional — supported, the client drops the tasks/get round trips; unsupported, nothing breaks.

In practice this noticeably cuts network traffic for short-TTL tasks that need frequent checking (e.g. an operation that takes seconds but is still processed asynchronously) — instead of a fresh tasks/get on every pollIntervalMs cycle, the client waits on a single subscription.

Cancellation: Cooperative Cancel

The client can try to cancel a task at any time with tasks/cancel. But you need to know exactly what this means server-side: cancellation is cooperative. The server acknowledges the intent but isn't obligated to actually stop the work. The official implementation guidance puts it this way: the server acknowledges cancellation requests with an empty result and fulfills them when possible, but because cancellation is cooperative, a task can still land on a terminal status other than cancelled — if the underlying work (e.g. a cloud deployment job) has already reached an irreversible point, it may well close out as completed or failed. This matters especially for tools that wrap an external job system: don't design around the assumption that tasks/cancel stops the work instantly.

Client Support: Who Supports It Today?

Tasks is listed as an official extension in the main MCP spec — but "official in the spec" and "working in every client" are different things. The official client support table currently lists three extensions; Tasks is not among them:

Extension
Listed in the client support table?
MCP Apps
Yes
OAuth Client Credentials
Yes
Enterprise-Managed Authorization
Yes
Tasks
No

Tasks doesn't have a row in this table yet. That doesn't mean the extension doesn't work — its definition and schema are clear at the spec level — but instead of assuming which clients implement the io.modelcontextprotocol/tasks capability, verify it in your client's own docs or the server/discover response. If you're writing a server, don't skip testing with clients that aren't task-aware, in case negotiation fails.

Moving a Long Build/Deploy Tool to Tasks

Tasks' most natural use case is tools that already wrap an external system with its own job ID: cloud deployments, queued jobs, long-running async APIs. The logic: return a task when you create the job, resolve it when the work finishes.

typescript
1// Server side — wrap the external job system in a task
2type DeployParams = { service: string; ref: string };
3type Job = {
4 id: string;
5 createdAt: string;
6 finished: boolean;
7 succeeded: boolean;
8};
9 
10declare const cloudProvider: {
11 createDeployment(params: DeployParams): Promise<Job>;
12 getDeployment(id: string): Promise<Job>;
13};
14 
15const DEPLOY_TTL_MS = 3_600_000;
16 
17async function handleDeployTool(params: DeployParams) {
18 const job = await cloudProvider.createDeployment(params);
19 return {
20 resultType: "task" as const,
21 taskId: job.id,
22 status: "working" as const,
23 createdAt: job.createdAt,
24 lastUpdatedAt: job.createdAt,
25 ttlMs: DEPLOY_TTL_MS,
26 pollIntervalMs: 8000,
27 };
28}
29 
30async function handleTasksGet(taskId: string) {
31 const job = await cloudProvider.getDeployment(taskId);
32 const base = {
33 resultType: "complete" as const,
34 taskId,
35 createdAt: job.createdAt,
36 lastUpdatedAt: new Date().toISOString(),
37 ttlMs: DEPLOY_TTL_MS,
38 };
39 
40 // Non-terminal status: only status; no result/error.
41 if (!job.finished) {
42 return { ...base, status: "working" as const };
43 }
44 
45 // CompletedTask: result is REQUIRED.
46 if (job.succeeded) {
47 return {
48 ...base,
49 status: "completed" as const,
50 result: {
51 content: [{ type: "text", text: "Deploy " + taskId + " completed." }],
52 },
53 };
54 }
55 
56 // FailedTask: error is REQUIRED.
57 return {
58 ...base,
59 status: "failed" as const,
60 error: { code: -32000, message: "Deploy " + taskId + " failed." },
61 };
62}

This pattern lets you reuse a job ID that already exists in your own infrastructure as the MCP task ID — no need to invent a separate state machine. The client-side difference is just as big: a long deploy is no longer a single blocking request, but a task whose progress can be tracked, that's resilient to crashes, and that can be cancelled when needed.

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

I put together a checklist you can quickly run through before turning on the Tasks extension in your own MCP server — it covers both the capability negotiation side and the task lifecycle side.

FAQ

What is MCP Tasks and when should it be used?

MCP Tasks (io.modelcontextprotocol/tasks) is the official MCP extension that lets a server return a durable taskId instead of an immediate result. It's recommended for CI pipelines, batch data processing, steps needing human approval, external job systems that take minutes or hours (cloud APIs returning a job ID), and clients whose connection may be unstable.

How do you avoid a timeout on a long-running MCP tool call?

Instead of blocking the request, the server returns a CreateTaskResult carrying resultType: "task", including the taskId, starting status, TTL, and recommended polling interval. The client closes the connection and queries periodically with tasks/get — the short timeouts imposed by intermediate layers no longer matter, since you rely on short, repeated queries instead of one long connection.

How is the input_required status handled?

When a task needs intermediate input, such as human approval, its status moves to input_required; the tasks/get response carries the pending requests in inputRequests. The client responds with a tasks/update call, filling in the matching keys in inputResponses. The schema requires every inputResponses key to correspond to a key currently pending in inputRequests — preventing, at the schema level, a response to a request that's already answered or never existed.

Does an MCP task resume where it left off when the client crashes?

Yes — taskId is a durable handle; even if the client loses its connection or restarts while the work keeps running server-side, it can call tasks/get with the same ID and keep polling. This is the "crash resilience" design intent from the official docs. Cancellation (tasks/cancel) is separate and cooperative — the server accepts the request but isn't required to stop the work instantly.

Which clients need support to use Tasks?

Both client and server must announce io.modelcontextprotocol/tasks separately. The main spec lists Tasks as official, but the client support table has no row for it — verify support in your own environment instead of assuming it.

Update (September 2026)

The official support matrix changed after this guide was published: on September 8, 2026, a fourth row, Skills over MCP (io.modelcontextprotocol/skills), was added to the "Extension overview" table on the MCP Client Support Matrix page; the commit is titled "docs: add Skills extension overview and support tracking," and the page's dateModified is now September 13, 2026. Tasks still has no row of its own — so "don't assume client support, verify it" still holds today.

Conclusion

Tasks is the official way to bring long-running work into the protocol without breaking MCP's synchronous request/response assumption: a durable taskId, a clear state machine, explicit TTL and polling-interval contracts, the input_required mechanism folding human approval into the flow, and notifications that make polling optional. Before turning it on, get capability negotiation right on both sides, and test without assuming client support.

If you want to see this round of MCP changes in a broader context, take a look at the guide to MCP's stateless transition and the roots/sampling/logging removal transition plan, which focuses on capabilities removed in the same spec cycle. For the broader integration side of the protocol, the guide to AI integration with MCP and the Claude Code MCP guide will be useful. For handling long-running work at the agent-loop level instead — a different problem from the one Tasks solves — see the agentic AI tool-use planner loop guide.

Sources

Tags

#MCP#Model Context Protocol#MCP Tasks#async tools#agentic AI#JSON-RPC#AI tooling
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share