Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Tasks (extension)

Audience:engineerarchitectsecurityMCP spec 2026-07-28

Now an official extension, redesigned on the way out of core. Tasks were introduced in MCP 2025-11-25 as an experimental core utility. The 2026-07-28 revision moves them out of the core protocol into the official io.modelcontextprotocol/tasks extension and redesigns the model: polling via tasks/get replaces the blocking tasks/result, a new tasks/update carries client-to-server input, tasks/list is removed, and servers may return task handles unsolicited (SEP-2663). This page describes the extension as published; the changes from the 2025-11-25 core design are listed below because SDKs and clients that still speak the old shape are in the wild.

Plain-language explanation

TL;DR: A task turns a slow MCP request into a durable, pollable one. Instead of holding the connection open until the work finishes, the server immediately returns a CreateTaskResult: a taskId plus status, marked resultType: "task". The client then polls with tasks/get until the task reaches a terminal status, at which point the tasks/get response itself carries the final result (or error). If the task needs something from the user mid-flight, it parks in input_required and the client answers via tasks/update. It is the protocol-level mechanism for exactly the shape the async-jobs pattern hand-rolls.

Tasks exist because some operations are too slow to block on: an expensive computation, a batch job, a call to an external job API. On Vercel the pressure is sharper still, because a Vercel Function invocation has a hard maxDuration budget; “hold the connection open and hope” is not merely fragile there, it has a deadline. Without tasks you invent your own handle-and-poll convention per server. The extension standardizes the handle, the polling, the input round-trip, and the lifecycle.

Two design decisions changed with the move to an extension, and both fit the stateless 2026-07-28 revision. First, tasks are server-directed: the client opts in once via the extension capability, and the server decides per request whether to return a task instead of a direct result; there is no per-request task flag and no per-tool warmup. Second, tasks are now client-polls-server only: the stateless revision removed server-initiated requests entirely, so the 2025-11-25 notion of a server task-augmenting a sampling/createMessage it sends to the client no longer has anything to attach to.

Formal protocol perspective

Negotiation. The tasks extension uses the standard 2026-07-28 extension mechanism: the client includes io.modelcontextprotocol/tasks in the extensions field of the io.modelcontextprotocol/clientCapabilities it sends in each request’s _meta; the server advertises the same key in the extensions of the capabilities it returns from server/discover. A server must not return a task to a client that did not declare support.

Task creation. In response to a supported request (for example tools/call), the server may return a CreateTaskResult, identified by resultType: "task", containing a Task object: a unique taskId, the initial status, ttlMs (retention), and pollIntervalMs (suggested polling cadence). The task is durably created before the response is sent, so the handle the client receives is always redeemable.

The three operations:

  • tasks/get: poll a task’s status (clients respect the returned pollIntervalMs). The response carries the current Task; for terminal states it also carries the outcome inline: a result field (what the original request would have returned synchronously) on completed, or an error field (the JSON-RPC error) on failed.
  • tasks/update: supply inputResponses keyed to the outstanding inputRequests of a task in input_required. The server acknowledges with an empty result and ignores responses for unknown or already-satisfied keys.
  • tasks/cancel: request cancellation at any time. Cancellation is cooperative: the server acknowledges the intent but is not obligated to stop the work, and the task may still reach a terminal status other than cancelled.

Mid-flight input. When the task needs something (typically the moral equivalent of an elicitation), it moves to input_required and the tasks/get response includes an inputRequests map. The client presents those to the user or model and answers via tasks/update. This is the task-shaped sibling of the MRTR retry loop: in both, the server returns its questions instead of initiating a request.

Notifications. Servers can push status updates via notifications/tasks, delivered through the subscriptions/listen mechanism, each carrying the full task state. Polling remains the default; notifications are an optimization, not a requirement.

Lifecycle

CreateTaskResultreturnedserver needs inputtasks/update withinputResponsestasks/cancel honoredtasks/cancel honoredworkinginput_requiredcompletedfailedcancelled
CreateTaskResultreturnedserver needs inputtasks/update withinputResponsestasks/cancel honoredtasks/cancel honoredworkinginput_requiredcompletedfailedcancelled
Mermaid state diagramOpen in Mermaid Live Editor
Diagram source (Mermaid)
stateDiagram-v2
    [*] --> working: CreateTaskResult returned
    working --> input_required: server needs input
    input_required --> working: tasks/update with inputResponses
    working --> completed
    working --> failed
    working --> cancelled: tasks/cancel honored
    input_required --> completed
    input_required --> failed
    input_required --> cancelled: tasks/cancel honored
    completed --> [*]
    failed --> [*]
    cancelled --> [*]

The statuses are working, input_required, completed, failed, and cancelled. completed, failed, and cancelled are terminal: once reached, the task’s state does not change. failed means a JSON-RPC error occurred during execution and the error field has the details; completed means the result field contains the final output.

Request / result flow

ServerClientServerClientloop[poll until terminal, respect pollIntervalMs]server needs user inputafter ttlMs the task may be purgedtools/call with tasks extension capability in _metaCreateTaskResult with resultType task, taskId, workingtasks/get with taskIdTask statusTask status input_required with inputRequeststasks/update with inputResponsesacknowledgedtasks/get with taskIdTask completed with result inline
ServerClientServerClientloop[poll until terminal, respect pollIntervalMs]server needs user inputafter ttlMs the task may be purgedtools/call with tasks extension capability in _metaCreateTaskResult with resultType task, taskId, workingtasks/get with taskIdTask statusTask status input_required with inputRequeststasks/update with inputResponsesacknowledgedtasks/get with taskIdTask completed with result inline
Mermaid sequence diagramOpen in Mermaid Live Editor
Diagram source (Mermaid)
sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: tools/call with tasks extension capability in _meta
    S-->>C: CreateTaskResult with resultType task, taskId, working
    loop poll until terminal, respect pollIntervalMs
        C->>S: tasks/get with taskId
        S-->>C: Task status
    end
    Note over C,S: server needs user input
    S-->>C: Task status input_required with inputRequests
    C->>S: tasks/update with inputResponses
    S-->>C: acknowledged
    C->>S: tasks/get with taskId
    S-->>C: Task completed with result inline
    Note over S: after ttlMs the task may be purged

What changed from the 2025-11-25 core design

If you last read this page (or an SDK) against 2025-11-25, recalibrate:

  • Opt-in moved from the request to the capability. The per-request task augmentation field and the per-tool execution.taskSupport declaration are gone; the client declares the extension once per request in _meta capabilities, and the server decides when to return a task, including unsolicited.
  • tasks/result is gone. There is no blocking retrieval call; the terminal tasks/get response carries the result or error inline.
  • tasks/update is new. Under the core design, input_required surfaced a nested server-initiated elicitation or sampling request; those no longer exist, so input flows client-to-server through tasks/update.
  • tasks/list is removed.
  • Field renames: ttl is now ttlMs, pollInterval is now pollIntervalMs, and CreateTaskResult is identified by resultType: "task".
  • Directionality collapsed. The requestor/receiver framing (either side could create tasks on the other) is gone with server-initiated requests; the client polls the server, full stop.

Common misconceptions

  • CreateTaskResult contains the answer. No: it contains the taskId, status, ttlMs, and pollIntervalMs. The answer arrives in a later tasks/get response, once the status is terminal.
  • There is a tasks/result call to fetch the outcome. Not anymore: that was the 2025-11-25 core design. Under the extension, the terminal tasks/get response carries the outcome inline.
  • The status notification can be relied on. No: notifications/tasks rides the opt-in subscriptions/listen stream and support varies; polling tasks/get is the default and always works.
  • tasks/cancel stops the work. Not necessarily: cancellation is cooperative. The server acknowledges the intent; the task may still land on completed or failed.
  • A failed tool call is a protocol error. No: a task whose underlying work produced a JSON-RPC error lands in failed with the error field populated; a tool that ran and returned isError: true is a completed task whose result carries that tool-level error. The two layers stay distinct, exactly as in a direct call.
  • Tasks make a serverless server durable by themselves. No: tasks standardize the conversation about background work. The work, and the task state, still need somewhere to live that outlives a function invocation. See the Vercel reality section below.

Debugging notes

  • Task not found on tasks/get - the server may have purged it after ttlMs, or the taskId is from another principal’s context. Treat “not found” as possibly-expired first, bug second.
  • Stuck in input_required - the server is waiting on tasks/update. Check that you read the inputRequests map from the tasks/get response and that your inputResponses keys match; the server silently ignores unknown keys.
  • You returned a task and the client hung - the client never declared io.modelcontextprotocol/tasks in its per-request capabilities and does not understand resultType: "task". Never return a task to a client that did not opt in; fall back to a synchronous result or the async-jobs pattern.
  • cancelled never arrives after tasks/cancel - legal: cancellation is cooperative and the work may finish anyway. Poll to a terminal state rather than assuming.
  • Task vanishes between polls on Vercel - you stored task state in process memory. A new invocation is a new (or best-effort reused) instance; Fluid compute reuse is never a correctness guarantee. See below.

Security implications

The taskId is the capability that grants access to a task’s status, its result, and its input channel, so it is the security pivot:

  • Bind tasks to the authorization context. Scope each task to the verified principal that created it, and refuse tasks/get, tasks/update, and tasks/cancel for tasks outside that context. On Vercel that principal comes from withMcpAuth, never from a tool argument; see Where the principal comes from and the authorization checklist.
  • No auth context? Then taskIds must be cryptographically random with enough entropy to resist guessing, and ttlMs should be short: an unauthenticated task id is a bearer token for the result.
  • tasks/update is an input surface. It injects data into a running job; validate inputResponses exactly as you would tool arguments, against the schema of what was asked for.
  • Rate-limit and cap. Limit concurrent tasks per principal and enforce a maximum ttlMs to prevent enumeration and resource-exhaustion attacks; clean up expired tasks; log task lifecycle events for audit. See Monitoring and audit.

What the TypeScript SDK actually ships

Verified against the installed v2 packages (@modelcontextprotocol/server 2.0.0 and @modelcontextprotocol/client 2.0.0, the line mcp-handler 2.1.1 peers on) in this repo’s examples/minimal-server (in the repository):

  • Schema types only, and of the old shape. The packages export the 2025-11-25 core task types (CreateTaskResult, GetTaskRequest, GetTaskPayloadRequest, ListTasksRequest, CancelTaskRequest, TaskStatusNotification, TaskAugmentedRequestParams, the per-tool taskSupport field, RELATED_TASK_META_KEY). Nothing of the redesigned extension exists: no tasks/update anywhere in the typings.
  • No runtime task API at all. SDK v1 (1.26.0) shipped a working two-sided surface under its experimental/tasks export (registerToolTask, pluggable TaskStore, callToolStream); v2.0.0 exposes none of it. The server package’s exports are ., ./stdio, ./validators/ajv, ./validators/cf-worker, and ./_shims; none is a task runtime, and there is no task method on McpServer or Client.

Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.

Why this repo has no runnable tasks example

Two reasons, one harder than before:

  1. The released stack has no task runtime. As verified above, SDK v2 has no server-side or client-side task runtime API, old shape or new: no tasks/update, no pluggable TaskStore, no registerToolTask. The wire itself is not the obstacle: the pinned handler serves 2026-07-28 natively, including server/discover, so a server could advertise the io.modelcontextprotocol/tasks extension in its capabilities and read the client’s per-request _meta opt-in. What it could not do is run a task, so an example would have to hand-roll the extension’s state machine and wire messages against typings that still describe the superseded core design, teaching idioms that match neither the SDK nor the spec.
  2. Task state needs a durable home, and that is on you. Task state must survive across invocations: the tasks/get poll may land on a different invocation than the tools/call that created the task, and a stateless Vercel Function gives you no durable process (see Serverless sessions). A production deployment needs a task store backed by Redis or Postgres, which would drag deployed infrastructure into a test suite this repo requires to run offline. The extension’s own model agrees: the task must be durably created before the CreateTaskResult is sent.

The durable, portable shape for background work on Vercel today is the hand-rolled one: the async-jobs pattern with its runnable examples/async-jobs-server (in the repository), backed by Queues or Workflows and an external job store.

Relationship to the async-jobs pattern

async-jobs is the application-level pattern (an opaque handle, progress, cancellation, idempotent retrieval) that servers implemented by hand before there was protocol support. The tasks extension is the protocol formalizing that pattern, and the 2026-07-28 redesign moved it closer to the hand-rolled shape: CreateTaskResult is the handle, tasks/get is the poll that also returns the outcome, tasks/cancel is the cancel, and there is no blocking retrieval call left to hold a connection open. Because the extension is opt-in on both sides and the released SDK does not implement it, the hand-rolled pattern remains the portable approach today; the extension is where it is heading, and it now needs negotiation (server/discover capabilities) rather than guesswork to adopt.

Bibliography