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
CallToolResultcompletes the task,isErrorincluded. A tool-level failure iscompletedwithresult.isError, never thefailedstatus. - An
InputRequiredResultparks the task ininput_requiredwith itsinputRequestsmap. 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 arequestStatebut noinputRequests, an unresumable state no poll could answer. - A protocol exception fails the task and inlines
error.codeanderror.message.failedis reserved for protocol errors. - Cooperative cancellation settles the task as
cancelled.tasks/cancelcancels 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.