MCP Async Tasks: Building long-running workflows for AI Agents — WorkOS

MCP Async Tasks: Building long-running workflows for AI Agents

What MCP Tasks are, why they matter, and the full technical guide to implementing them.

Maria Paktiti

The 2025-11-25 revision of the Model Context Protocol (MCP) introduces Tasks: an experimental primitive that upgrades MCP from “synchronous tool calls” to a call-now, fetch-later protocol. In practical terms, Tasks let an MCP request return immediately with a durable handle, while the real work continues in the background and can be polled or subscribed to later.

Even though Tasks are still labeled experimental, they’re already one of the most important changes MCP has shipped. They close a production gap every serious agent developer has run into: timeouts, blocked sessions, and ad-hoc async hacks for anything that takes longer than a typical RPC round-trip. Tasks standardize long-running operations across the ecosystem so clients, servers, and SDKs can interoperate without bespoke side channels.

This article goes deep on how Tasks work, why the design matters, and how to implement them safely.

Why MCP Tasks are a big deal

Before Tasks, MCP requests were effectively synchronous: a client calls tools/call, waits, and receives a result. That model breaks under real workloads:

Tasks fix this by introducing a cross-request async state machine. Any request type that opts in can be augmented into a Task, and clients can rely on uniform semantics for status, progress, results, and cancellation.

The feature is experimental mainly because the maintainers want room to tune ergonomics and edge-cases (especially around SDK helpers and UX patterns). But the core protocol pieces are already solid enough that SDKs are actively implementing SEP-1686 across languages.

Mental model: Tasks as durable request executions

In MCP Tasks, every async operation has two roles:

The protocol is requestor-driven: the requestor decides when to create a task and how to orchestrate polling or concurrent tasks, while the receiver decides which requests are task-augmentable and how long tasks live.

Think of a task as a small, durable state machine plus a results pointer.

Capability negotiation: Opting in, per request type

Tasks aren’t assumed in MCP. Both sides have to advertise support during initialization, and they do it granularly: a peer doesn’t just say “I support tasks,” it says which kinds of requests may be task-augmented. That keeps async behavior predictable and prevents surprise background jobs in places that still need to be synchronous.

Server capabilities example

The following example tells us three concrete things about the server:

  1. It can accept task-augmented requests for the tools/call method.
  2. It implements task lifecycle APIs: By advertising list and cancel, the server is promising support for tasks/list and tasks/cancel in addition to tasks/get / tasks/result. So clients can enumerate in-flight jobs and stop them, rather than only polling.
  3. Anything not listed is still synchronous: If a request type isn’t present under requests, clients must not try to task-augment it with this server.
{
  "capabilities": {
    "tasks": {
      "list": {},
      "cancel": {},
      "requests": {
        "tools": { "call": {} }
      }
    }
  }
}

Client capabilities example

This flips the perspective. Here the client is effectively saying:

{
  "capabilities": {
    "tasks": {
      "list": {},
      "cancel": {},
      "requests": {
        "sampling": { "createMessage": {} },
        "elicitation": { "create": {} }
      }
    }
  }
}

That matters because in MCP either side can be a requestor. For example, a server running a long task might need to elicit user input mid-workflow. With this capability, the client is declaring it supports those incoming async requests and can participate in that multi-step task lifecycle.

Rules to remember:

  1. If capabilities.tasks is missing, don’t create tasks.
  2. The capabilities.tasks.requests set is exhaustive: if a type isn’t listed, it can’t be task-augmented.
  3. tasks.list and tasks.cancel are separately negotiated; a peer may support task creation but not listing, etc.

Tool-level negotiation: execution.taskSupport

Tool calls get an extra layer: in tools/list, each tool can declare:

This is enforced only if the server also declared tasks.requests.tools.call. Otherwise tasks are forbidden regardless of tool metadata.

This two-tier negotiation (global + per-tool) is subtle but powerful: it lets a server say “Tasks exist,” while a specific tool can say “I’m always fast, don’t bother,” or “I’m batchy and slow, please always task-augment.”

How to create an async task

To request async execution, the requestor adds a task field inside the normal request params. The only currently defined task parameter is ttl (time-to-live in milliseconds). If the receiver supports Tasks for that request type, it will treat this as a long-running job and return a task handle immediately instead of blocking for the final result.

Example: task-augmented tools/call:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "New York" },
    "task": { "ttl": 60000 }
  }
}

The receiver returns either the operation result directly (if you didn’t include task, or if Tasks aren’t supported here) or the metadata of a newly created task.

Example response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "task": {
      "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
      "status": "working",
      "statusMessage": "The operation is now in progress.",
      "createdAt": "2025-11-25T10:30:00Z",
      "lastUpdatedAt": "2025-11-25T10:40:00Z",
      "ttl": 60000,
      "pollInterval": 5000
    }
  }
}

Here’s what each field in that task object is telling you:

At this point the receiver has accepted the work, and the requestor has a durable handle to track it.

Handling input_required (tasks + elicitation)

input_required is the bridge between async execution and interactive workflows. When a receiver needs more info to continue, it:

  1. Moves task to input_required.
  2. Sends elicitation (or other input request) tagged with the same related-task id.
  3. The requestor should preemptively call tasks/result to wait for the next stage while still polling status if desired.

This is how Tasks support multi-step back-and-forth without inventing a new control plane.

Polling for status: tasks/get

Requestors poll by calling tasks/get, and should respect the server’s suggested pollInterval. Polling continues until the task finishes or input_required is encountered.

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tasks/get",
  "params": { "taskId": "..." }
}

Response returns the full Task object.

Retrieving results: tasks/result

Results are fetched separately, after the task finishes. tasks/result is blocking: it holds the response until the task is terminal.

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tasks/result",
  "params": { "taskId": "..." }
}

Rules:

This symmetry is key: tasks don’t invent a new result format; they delay the existing one.

Associating messages to tasks

A task in MCP isn’t just “one request that finishes later.” It can turn into a mini workflow that spans multiple MCP interactions. While a task is running, the receiver may send additional MCP requests or notifications that are part of the same execution. For example, a long-running tool call might:

All of those are task-related messages: they’re not new, unrelated RPCs, they’re steps in the same async job.

To keep every step correctly attached to the right background execution, MCP requires task-related messages to carry a metadata tag:

"_meta": {
  "io.modelcontextprotocol/related-task": { "taskId": "..." }
}

What this accomplishes:

A couple of important nuances:

Progress and status notifications

Notifications are push messages a receiver can send to proactively update the requestor about a task. They differ from polling in one key way: polling is requestor-driven and authoritative, while notifications are receiver-driven and best-effort. In practice that means you poll tasks/get for the source of truth, and use notifications only to learn changes sooner and improve UX. If a notification is missed, polling still gets you back in sync.

There are two optional notification paths:

Cancelling tasks: tasks/cancel

Requestors can explicitly cancel a task:

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

The following rules apply:

Listing tasks: tasks/list

If supported, receivers return tasks in pages with an opaque cursor (nextCursor). Any task retrievable via tasks/get must appear in tasks/list for that requestor.

This is especially useful for UIs that want to show “background jobs” or reconnect to tasks after a restart.

Error model

Tasks use two layers of error reporting:

  1. Protocol errors (standard JSON-RPC) for bad taskId, invalid cursor, etc.
  2. Execution errors expressed as status: failed, with diagnostics in statusMessage.

Critically, tasks/result for a failed task returns the same error the original request would have returned, preserving compatibility.

Security considerations for MCP Async Tasks

Because tasks are fetched later by taskId, servers must treat task IDs as sensitive capability handles. Every task should be bound to the same authorization context (user / tenant / API client) that created it, and all follow-up calls must enforce that binding.

Concretely: tasks/get, tasks/result, and tasks/cancel should only succeed if the caller’s auth context matches the task’s; otherwise they should fail as if the task doesn’t exist.

Likewise, tasks/list must be filtered so a caller only sees tasks from their own context. If your deployment doesn’t have auth contexts, then task IDs must be cryptographically unguessable, TTLs should be short, and task endpoints should be rate-limited to prevent enumeration or data leakage.

Implementation notes for MCP Async Tasks

A few practical patterns emerge from SEP-1686 and early SDK work:

Server side

Client / agent side

Secure your Tasks with WorkOS MCP Auth

Async Tasks make MCP dramatically more powerful, but they also raise the stakes for security. A long-running task can span minutes or hours, emit follow-on requests, and expose results later via taskId. That means every task needs to be tied to a real user or tenant, with least-privilege access to the exact tools and resources it’s allowed to touch.

WorkOS makes that easy. AuthKit acts as an OAuth 2.1–compatible authorization server for MCP, aligned with the latest spec, so you can add standards-compliant auth to your MCP server without re-implementing OAuth edge cases yourself. It supports the core pieces you need for production-grade Tasks: PKCE flows, scoped tool permissions, secure token issuance/validation, and multi-tenant isolation, all with minimal MCP-specific glue.

If you’re building an MCP server that will run long-lived background work, secure it from day one. WorkOS MCP Auth lets you ship fast while staying compliant with the protocol and safe for real user data.

Final thoughts

Tasks may be experimental, but they’re foundational. They turn MCP into a protocol that can model real work: background jobs, human-in-the-loop steps, and multi-minute workflows, all while preserving interoperability and the simple JSON-RPC mental model MCP started with.

If you’re building an MCP server today, Tasks are the new default for anything slow. If you’re building a client or agent framework, Tasks are the key to safe concurrency and good user experience. And if you’re building enterprise MCP integrations, this is the primitive that makes “agentic automation” feel like infrastructure instead of a demo.

We’ll be watching how the experimental edges settle, but the direction is clear: async is now a first-class citizen in MCP.