Model Context Protocol · AI agents · Distributed systems

Build Long-Running MCP Tools with the Experimental Tasks Utility

Design a durable MCP tool call with polling, deferred results, input-required handling, cancellation, and tenant-safe retention.

Published
Updated
Code examples
Illustrative
Reading time
6 minutes

Use an MCP Task when a supported request cannot reliably finish inside one request-response window and the requestor needs to poll or retrieve the result later. A Task wraps an existing request; it does not replace your job queue, workflow engine, database, or authorization system.

This guide designs a long-running report tool against MCP specification version 2025-11-25. Tasks were introduced in that version and are explicitly experimental, so isolate their protocol adapter and pin the negotiated version. The normative Tasks specification should remain the source of truth.

Know what a Task guarantees

A normal tools/call returns its CallToolResult directly. A task-augmented call first returns a CreateTaskResult; the caller later obtains the underlying tool result through tasks/result. The receiver generates the task ID and owns the lifecycle, while the requestor drives polling and result retrieval.

The protocol defines five states:

StateMeaningTerminal?
workingThe receiver is processing the requestNo
input_requiredProgress depends on a request back to the requestorNo
completedThe request succeededYes
failedThe underlying request failedYes
cancelledCancellation was acceptedYes

Every Task starts as working. A terminal Task must never transition again. input_required may return to working or move directly to a terminal state. These transition rules are protocol requirements, not suggestions (task lifecycle requirements).

Tasks make execution state retrievable. They do not promise exactly-once effects, durable storage after the TTL, automatic retries, or successful interruption after cancellation.

Negotiate support at two levels

Both the server and the individual tool must permit task execution. A server advertises tasks.requests.tools.call; a tool then declares execution.taskSupport as required, optional, or forbidden. If the tool omits that field, the default is effectively forbidden.

An illustrative initialization capability is:

{
  "capabilities": {
    "tasks": {
      "list": {},
      "cancel": {},
      "requests": {
        "tools": { "call": {} }
      }
    }
  }
}

And the corresponding tool description contains:

{
  "name": "build_account_report",
  "description": "Build a read-only report for one account and date range.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "accountId": { "type": "string", "minLength": 1 },
      "from": { "type": "string", "format": "date" },
      "to": { "type": "string", "format": "date" }
    },
    "required": ["accountId", "from", "to"],
    "additionalProperties": false
  },
  "execution": { "taskSupport": "required" }
}

Choose required only when synchronous execution is genuinely unsupported. Choose optional if small inputs finish inline but larger ones should be deferred. A client must not infer support from a slow-looking description; it must use negotiated capabilities and the tool field.

Map the protocol to a durable job record

The receiver needs an authoritative record that survives process restarts. A minimal application model is:

type JobStatus =
  | "working"
  | "input_required"
  | "completed"
  | "failed"
  | "cancelled";

type ReportJob = {
  taskId: string;
  principalId: string;
  tenantId: string;
  status: JobStatus;
  createdAt: string;
  lastUpdatedAt: string;
  expiresAt: string | null;
  requestedPollIntervalMs: number;
  input: Readonly<{
    accountId: string;
    from: string;
    to: string;
  }>;
  result: unknown | null;
  errorCode: string | null;
  cancellationRequestedAt: string | null;
};

This is application pseudocode, not an SDK type. Store the authenticated principal and tenant with the Task at creation. Every tasks/get, tasks/result, tasks/list, and tasks/cancel lookup should include those fields in its database predicate. Treat a task ID as an identifier, never as authorization.

The create handler should perform these steps atomically where practical:

  1. Authenticate and authorize the caller for the requested account.
  2. Validate bounded input, including date order and maximum range.
  3. Generate a unique opaque task ID at the receiver.
  4. Persist a working job and its effective TTL.
  5. Enqueue work with the task ID as the idempotency key.
  6. Return the Task promptly rather than waiting for the report.

The caller can request a TTL, but the receiver may override it. Return the effective ttl, not merely the requested value. Once that lifetime has elapsed, the receiver may delete the Task and result even if execution never reached a terminal state (TTL requirements).

Implement polling and result retrieval correctly

A client polls with tasks/get and should respect a returned pollInterval. It continues until the status is terminal or becomes input_required. Status notifications are optional, so they may improve responsiveness but cannot replace polling.

async function waitForTask(taskId: string) {
  for (;;) {
    const task = await getTask(taskId);

    if (task.status === "input_required") {
      return { kind: "input_required" as const, task };
    }

    if (["completed", "failed", "cancelled"].includes(task.status)) {
      return { kind: "terminal" as const, task };
    }

    await delay(task.pollInterval ?? 2_000);
  }
}

This is deliberately a policy sketch: add an overall deadline, abort signal, retry backoff for transient network failures, and jitter in a real client. Do not clamp the interval below the receiver's requested value.

tasks/result behaves differently: it blocks until the Task reaches a terminal state, then returns exactly the result shape the underlying request would have returned. For a tool call that is a CallToolResult or a JSON-RPC error. Polling is preferable when you need progress UI or cannot keep a request open; result retrieval is convenient when your transport and deadline can tolerate a wait.

Handle required input as a nested interaction

When execution requires a server-to-client message, such as an elicitation, the receiver moves the Task to input_required and associates the nested request using:

{
  "_meta": {
    "io.modelcontextprotocol/related-task": {
      "taskId": "opaque-receiver-generated-id"
    }
  }
}

The requestor should call tasks/result when it sees input_required so it can receive and answer the nested request. After the needed input arrives, the receiver usually returns the Task to working. All Task-related requests, responses, and notifications must carry the related-task metadata where the specification requires it.

Do not use this mechanism to request passwords or access tokens through a form. MCP elicitation requires URL mode for sensitive information; the elicitation guide explains that boundary.

Make cancellation honest

tasks/cancel is a request to stop, not proof that an external effect was reversed. After accepting cancellation, the receiver must mark the Task cancelled, and that state cannot change even if underlying work later completes. Your worker therefore needs cooperative cancellation checks before expensive stages and, especially, before side effects.

For a write operation, separate preparation from commitment:

validate → calculate preview → await approval → commit once → record receipt

Use an idempotency key for the commitment. If cancellation races with the commit, return an audit-safe status message and preserve the external receipt internally. Never imply rollback unless you actually performed a compensating action.

Production failure modes

  • Worker accepted, enqueue failed: create the job and outbox event in one transaction, then dispatch from the outbox.
  • Duplicate delivery: make each worker stage idempotent and use the task ID as a deduplication key.
  • Polling storm: return realistic poll intervals, add client jitter, and rate-limit by principal.
  • Result disappeared: make retention visible and distinguish expired from unknown identifiers in internal telemetry without leaking cross-tenant existence.
  • Notifications were lost: rely on tasks/get; notifications are optional by specification.
  • Cancelled work kept running: propagate cancellation to workers and external calls, but retain the terminal cancelled protocol state.
  • Task list leaks metadata: apply the same principal-and-tenant predicate to listing as to direct retrieval.

Decide whether Tasks are the right layer

Use MCP Tasks when a peer supports the 2025-11-25 feature, deferred retrieval is part of the client interaction, and you can tolerate an experimental contract. Use a normal call when work reliably finishes within a bounded request. Use your workflow engine's own job API when MCP interoperability adds no value.

Before release, verify capability negotiation, valid protocol state transitions, durable identity binding, idempotent execution, bounded polling, expiry, result authorization, cancellation races, and behavior when either peer lacks Task support.

For the non-Task foundation, start with a safe MCP server and a typed MCP client.