Skip to content

Tasks

The tasks extension (io.modelcontextprotocol/tasks, SEP-2663) lets a tool call run as a long-lived task the client polls, instead of holding the request open. It ships in Nexus\Mcp\Extension\Tasks. Like every extension, it is disabled until you enable it explicitly:

use Nexus\Mcp\Extension\Tasks\Server\TasksServerExtension;
use Nexus\Mcp\Extension\Tasks\Server\TaskSupport;
use Nexus\Mcp\Extension\Tasks\Server\ToolTaskPolicy;
use Nexus\Mcp\Server\ServerBuilder;

$server = (new ServerBuilder())
    ->setServerInfo('demo', '1.0.0')
    ->register($tools)
    ->enableExtension(new TasksServerExtension(
        toolPolicies: [
            'slow_compute' => new ToolTaskPolicy(support: TaskSupport::Optional),
            'batch_import' => new ToolTaskPolicy(support: TaskSupport::Required),
        ],
    ))
    ->build();

Enabling the extension advertises the capability and serves tasks/get, tasks/update, and tasks/cancel. It also wraps the tools/call handler in a broker that decides per request whether the call runs synchronously or as a task. tasks/list and the v1 tasks/result stay unregistered, so both answer -32601.

The server must serve tools/call, so register at least one tool, or build() fails: a decorated method needs a handler to wrap. Enable each extension instance on exactly one builder, since the broker binds the built server's tools/call chain.

Per-tool policies

The broker consults ToolTaskPolicy by tool name. A tool absent from the map is always synchronous. For a listed tool, TaskSupport decides what happens per request:

Support Client declared the extension Client did not declare it
Optional Runs as a task. Runs synchronously.
Required Runs as a task. Rejected with -32021, naming the extension in error.data.requiredCapabilities.

The declaration is per request. The broker reads the _meta io.modelcontextprotocol/clientCapabilities envelope, so a session that never negotiated the extension can still opt in on a single call. A task handle is never returned to a client that did not declare the extension.

ToolTaskPolicy(resolvesInputFirst: true) makes the broker delegate synchronously until the call carries a requestState continuation token. A tool that asks for input through InputRequiredResult then resolves its input rounds synchronously first, and only the resumed call becomes a task.

The task lifecycle

A task-bound call durably creates the record, starts the tool in a background fiber, and answers immediately with a flat CreateTaskResult (resultType: "task", taskId, status, timestamps, ttlMs, pollIntervalMs).

stateDiagram-v2
    [*] --> working: a task-bound tools/call answers with CreateTaskResult
    working --> completed: the fiber returns a CallToolResult, isError included
    working --> input_required: the fiber returns an InputRequiredResult
    input_required --> working: tasks/update answers every pending request
    working --> failed: a protocol exception, or an unresumable park
    working --> cancelled: tasks/cancel
    input_required --> cancelled: tasks/cancel

How the fiber settles the record

  • A CallToolResult completes the task, isError included. A tool-level failure is completed with result.isError, never the failed status.
  • An InputRequiredResult parks the task in input_required with its inputRequests map. Each request key must be unique over the task's lifetime, so a tool that asks again must mint a fresh key per round. Reusing one fails the task. So does parking with a requestState but no inputRequests, an unresumable state no poll could answer.
  • A protocol exception fails the task and inlines error.code and error.message. failed is reserved for protocol errors.
  • Cooperative cancellation settles the task as cancelled. tasks/cancel cancels the fiber's token.

Polling and updating

tasks/get projects the record. A completed task inlines result, a failed one inlines error, and an input_required one carries the pending inputRequests. An unknown taskId on any tasks method is -32602. Terminal states are sticky, so a completion that races a cancel loses, and tasks/cancel on a terminal task still acks.

tasks/update merges the client's inputResponses into the pending set. Responses for keys that were never issued are ignored, whatever their shape, and a partial answer keeps the task in input_required. Once nothing is pending, the tool call re-dispatches in a fresh background fiber with the accumulated responses and the stored requestState. That is byte-for-byte what a synchronous client would re-issue, so the tool's continuation token carries all the state.

A background task cannot reach the creating request's connection. Outbound notifications from a task fiber are dropped with a debug log, and outbound requests throw.

Limits

maxRunningTasks bounds how many task fibers run at once. It defaults to TasksServerExtension::DEFAULT_MAX_RUNNING_TASKS (1024). A tools/call that would start a task past the limit is refused with -32603 and data.limit before any record is created, and a tasks/update that would resume a parked task past it is refused the same way, leaving the task input_required for a later retry. Tasks run outside the dispatcher's maxInFlightDispatches budget, so this is the cap that bounds them.

Storage and retention

TasksServerExtension takes a TaskStoreInterface. The default is InMemoryTaskStore. A task that has not settled by createdAt + ttlMs is failed at its next observation. A terminal record, that failure included, stays readable for ttlMs milliseconds after it settles. A null ttl never force-fails the task and keeps its record until the store needs the room. The defaults are ttlMs: 300_000 and pollIntervalMs: 1_000, both constructor-tunable.

InMemoryTaskStore holds at most maxRecords (default InMemoryTaskStore::DEFAULT_MAX_RECORDS, 10 000), settled records included. Each createTask() drops the settled records whose retention has elapsed, in settle order. At the ceiling it fails every overdue task and drops every expired record once, then evicts the oldest settled record whatever its retention. It refuses the new task only when every record is still live, which the server answers with -32603.

A parked input_required task holds a record but no fiber slot, so maxRunningTasks does not bound it. Only its ttl does: an overdue parked task is failed at the ceiling, which frees its record. With ttlMs: null a parked task stays live until it is resumed or cancelled, so a client can fill the store with parked tasks and every later task-bound call is refused. Keep a finite ttl on a tool that returns InputRequiredResult.

The in-memory store confines tasks to the process. The record survives in whatever store you implement, but the in-process cancellation map does not, so cancellation is cooperative only within the serving process.

The broker is what upholds the never-to-a-non-declaring-client rule. A replaceRequestHandler('tools/call') replacement that returns a CreateTaskResult itself bypasses the broker, and upholding that rule is then the replacement's own job.

Routing headers

The tasks methods carry the SEP-2243 routing headers. Mcp-Name mirrors params.taskId on tasks/get, tasks/update, and tasks/cancel. A mismatched header is rejected with -32020, like any other header mismatch.

The client half is documented in Client tasks, and examples/tasks.php runs the whole loop in one process.