MCP Tasks
This guide covers the support for the MCP Tasks extension (io.modelcontextprotocol/tasks): how a tool creates a task, how a task is executed and polled, how to report progress and request input while a task is running, and how cancellation works.
What are MCP Tasks?
Not every tool call returns instantly. Some operations (installing a runtime, indexing a workspace, running a CI pipeline, waiting for a human approval) take seconds, minutes or longer. Holding the connection open until the work finishes is impractical: many clients and transport intermediaries impose timeouts, and a dropped connection loses the result.
The Tasks extension lets the server respond to a tools/call request with a durable task handle instead of the final result.
The client then polls the task until it reaches a terminal status and retrieves the final CallToolResult from the task:
Task creation is server-directed: the client declares the tasks extension in its capabilities, and the server decides per request whether to create a task.
The extension defines three methods - tasks/get, tasks/update and tasks/cancel - and the notifications/tasks notification.
|
Tasks are currently stateful
Even though the extension is designed for the stateless protocol (
A pluggable task store is planned as a follow-up. |
Installation
The Tasks extension is provided by a separate module. Add the following dependency to your project:
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-tasks</artifactId>
<version>2.0.0</version>
</dependency>
The API lives in the io.quarkiverse.mcp.server.tasks package.
As soon as the dependency is present, the io.modelcontextprotocol/tasks extension is advertised under capabilities.extensions of every server configuration, and the tasks/get, tasks/update and tasks/cancel methods are served.
Creating a task
Declare a Tasks parameter in a @Tool method and create a task with a handler that performs the work:
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolResponse;
import io.quarkiverse.mcp.server.tasks.Tasks;
public class LspTools {
@Tool(description = "Finds the references of a symbol")
ToolResponse references(String file, Tasks tasks) { (1)
if (!tasks.isSupported()) { (2)
return findReferences(file);
}
throw tasks.newTask()
.setTtl(Duration.ofHours(1)) (3)
.setPollInterval(Duration.ofSeconds(5))
.setHandler(task -> { (4)
task.setStatusMessage("Installing the runtime..."); (5)
installRuntime();
task.setStatusMessage("Starting the language server...");
startLanguageServer();
return findReferences(file);
}, false)
.create(); (6)
}
}
| 1 | Tasks is injected by the extension; it is bound to the current request. |
| 2 | The client may not support tasks; in that case the tool simply does the work synchronously. |
| 3 | The ttl and pollInterval are optional; see Time-to-live and polling interval. |
| 4 | A blocking handler, executed on a worker thread; the TaskContext gives access to the task. |
| 5 | The status message is included in the tasks/get responses. |
| 6 | create() durably creates the task, starts the handler asynchronously and returns a CreateTaskException. The exception is thrown from the tool method so that the server responds with the CreateTaskResult. |
When the tool throws the CreateTaskException, the server:
-
responds immediately with a
CreateTaskResult- the exception is anMcpResultException, see Error Handling, -
executes the handler asynchronously, and
-
stores the
ToolResponsereturned by the handler in the task once it completes.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "task",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"createdAt": "2026-09-18T10:30:00Z",
"lastUpdatedAt": "2026-09-18T10:30:00Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000
}
}
Execution model of the handler
The handler determines the execution model, following the same pattern as the programmatic feature definitions such as ToolManager.ToolDefinition:
| Handler | Execution model |
|---|---|
|
Blocking; executed on a worker thread ( |
|
Blocking; executed on a virtual thread ( |
|
Non-blocking; executed on the event loop ( |
A non-blocking tool may return the task with createAsync(), a convenience equivalent to Uni.createFrom().failure(create()):
@Tool(description = "Runs a pipeline")
Uni<ToolResponse> runPipeline(String repo, Tasks tasks) {
if (!tasks.isSupported()) {
return run(repo);
}
return tasks.newTask()
.setAsyncHandler(task -> run(repo))
.createAsync();
}
The handler is executed with its own CDI request context and, if security is enabled, with the SecurityIdentity of the request that created the task.
The identity is captured when the task is created. If proactive authentication is disabled and the identity cannot be resolved at that point, an error is logged and the handler is executed without a SecurityIdentity.
|
Capability negotiation
The tasks extension is advertised under capabilities.extensions of every server configuration:
{
"capabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
Tasks#isSupported() returns true only if the client declared the extension in its own capabilities - in the initialize request, or in the per-request io.modelcontextprotocol/clientCapabilities metadata for stateless clients:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "references",
"arguments": { "file": "Foo.cs" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}
The tool decides what to do for a client that did not declare the extension: typically it does the work synchronously and returns the standard CallToolResult.
If the tool cannot do so, it calls newTask() anyway; the call then fails with the error code -32021 (Missing Required Client Capability) as mandated by the specification:
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32021,
"message": "Missing required client capability: io.modelcontextprotocol/tasks",
"data": {
"requiredCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}
Time-to-live and polling interval
| Method | Default | Description |
|---|---|---|
|
|
The time-to-live of the task, measured from its creation and advertised as |
|
|
The polling interval suggested to the client, advertised as |
|
none |
The initial status message of the task. |
| Tasks are kept in memory. Expired tasks are removed periodically and when they are accessed; a task with an unlimited TTL is never removed. |
Polling and the task lifecycle
The client polls the task with tasks/get.
The response carries the current status and, for terminal statuses, the final result or error:
| Status | Meaning |
|---|---|
|
The handler is being executed. |
|
The tool is waiting for input from the client; see Requesting input while a task is running. |
|
The handler completed; the |
|
The handler failed with a JSON-RPC error (e.g. an |
|
The task was cancelled; see Cancellation. |
completed, failed and cancelled are terminal - once reached, the status does not change and the result is available until the task expires.
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "completed",
"statusMessage": "Starting the language server...",
"createdAt": "2026-09-18T10:30:00Z",
"lastUpdatedAt": "2026-09-18T10:31:12Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000,
"result": {
"content": [ { "type": "text", "text": "3 references found" } ],
"isError": false
}
}
}
A tasks/get, tasks/update or tasks/cancel request for an unknown or expired task results in a -32602 (Invalid params) error.
These methods also require the client to declare the tasks extension capability (-32021 otherwise).
| A task is bound to the server configuration it was created for. Task ids are random UUIDs that act as bearer tokens, as permitted by the specification; a task can therefore be polled after a reconnect, by any client that knows its id. |
Requesting input while a task is running
A running task may need input from the client - a confirmation, a form, an LLM completion or the list of roots.
Rather than sending a server-initiated request over the connection, the handler uses TaskContext#inputRequestBuilder():
import io.quarkiverse.mcp.server.Elicitation;
import io.quarkiverse.mcp.server.ElicitationRequest.StringSchema;
import io.quarkiverse.mcp.server.InputResponses;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolResponse;
import io.quarkiverse.mcp.server.tasks.Tasks;
public class GreetingTools {
@Tool(description = "Greets the user")
ToolResponse greet(Tasks tasks, Elicitation elicitation) {
throw tasks.newTask().setHandler(task -> {
InputResponses responses = task.inputRequestBuilder()
.addElicitationRequest("name", elicitation.requestBuilder() (1)
.setMessage("Please enter your name.")
.addSchemaProperty("name", new StringSchema(true))
.build())
.addRootsRequest("roots")
.build()
.sendAndAwait(); (2)
String name = responses.getElicitationResponse("name").content().getString("name"); (3)
return ToolResponse.success("Hello, " + name + "!");
}, false).create();
}
}
| 1 | The elicitation request is built as usual but it is not sent directly; it is added to the input request under a server-assigned key. |
| 2 | The task moves to the input_required status. sendAndAwait() blocks until all the requests are fulfilled; use send() to obtain a Uni<InputResponses> in a non-blocking handler. |
| 3 | The responses are keyed by the same identifiers. |
While the task is in the input_required status, the outstanding requests are included in the tasks/get responses, in the same shape as the inputRequests of a Multi Round-Trip Request:
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "input_required",
"createdAt": "2026-09-18T10:30:00Z",
"lastUpdatedAt": "2026-09-18T10:30:05Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000,
"inputRequests": {
"name": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please enter your name.",
"requestedSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
},
"roots": { "method": "roots/list", "params": {} }
}
}
}
The client fulfills the requests with one or more tasks/update requests; the task moves back to working once all the outstanding requests are answered:
{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/update",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"inputResponses": {
"name": { "action": "accept", "content": { "name": "Luca" } },
"roots": { "roots": [ { "uri": "file:///home/luca", "name": "home" } ] }
}
}
}
Responses for unknown or already satisfied keys are ignored. Each request key must be unique over the lifetime of the task.
|
Within a handler, always use |
Cancellation
A client cancels a task with tasks/cancel.
The request is acknowledged immediately and the task moves to the cancelled status.
Cancellation is cooperative: the handler is not forced to stop, but it is notified through the standard Cancellation API available from TaskContext#cancellation():
@Tool(description = "Indexes the workspace")
ToolResponse index(Tasks tasks) {
throw tasks.newTask().setHandler(task -> {
for (Path file : files()) {
task.cancellation().skipProcessingIfCancelled(); (1)
index(file);
}
return ToolResponse.success("Indexed");
}, false).create();
}
| 1 | Throws an OperationCancellationException if the task was cancelled; the exception is ignored by the server. |
A result produced by the handler after the cancellation is discarded.
A task waiting for input is cancelled as well: the pending TaskInputRequest#send() fails with an OperationCancellationException.
The notifications/cancelled notification is not used for task cancellation, as mandated by the specification.
|
Tasks can also be inspected and cancelled programmatically with the TaskManager bean:
import io.quarkiverse.mcp.server.tasks.TaskManager;
@Inject
TaskManager taskManager;
void cancelAll() {
for (TaskManager.TaskInfo task : taskManager) {
if (!task.status().isTerminal()) {
taskManager.cancelTask(task.id(), "Shutting down");
}
}
}
Limitations
-
Tasks are stateful: they are kept in memory and are only available on the server instance that created them.
-
Task status notifications (
notifications/tasksviasubscriptions/listen) are not supported yet; clients poll the task withtasks/get. -
Taskscan only be injected into@Toolmethods; tools registered programmatically viaToolManagercannot create tasks yet.
Streamable HTTP routing headers
Stateless clients using the Streamable HTTP transport must set the Mcp-Name header of tasks/get, tasks/update and tasks/cancel requests to the task id, so that intermediaries can route the requests to the server instance holding the task state.
The Mcp-Method header is set to the JSON-RPC method name as usual.
Testing
Tasks are tested like any other feature with McpAssured.
Declare the extension in the client capabilities, assert the CreateTaskResult via withRawAssert() and poll the task with a generic request:
import io.quarkiverse.mcp.server.ClientCapability;
import io.quarkiverse.mcp.server.tasks.TaskManager;
import io.quarkiverse.mcp.server.test.McpAssured;
import io.quarkiverse.mcp.server.test.McpAssured.McpStreamableTestClient;
@Test
public void testTask() {
McpStreamableTestClient client = McpAssured.newStreamableClient()
.setClientCapabilities(new ClientCapability(ClientCapability.EXTENSIONS,
Map.of(TaskManager.EXTENSION_ID, Map.of()))) (1)
.build()
.connect();
try (client) {
AtomicReference<String> taskId = new AtomicReference<>();
client.when()
.toolsCall("references")
.withArguments(Map.of("file", "Foo.cs"))
.withRawAssert(response -> { (2)
JsonObject result = response.getJsonObject("result");
assertEquals("task", result.getString("resultType"));
taskId.set(result.getString("taskId"));
})
.send()
.thenAssertResults();
client.when()
.message(client.newRequest("tasks/get") (3)
.put("params", new JsonObject().put("taskId", taskId.get())))
.withAssert(response -> {
JsonObject task = response.getJsonObject("result");
assertNotNull(task.getString("status"));
})
.send()
.thenAssertResults();
}
}
| 1 | The client declares the tasks extension. |
| 2 | The CreateTaskResult is asserted via the raw JSON-RPC response. |
| 3 | The task is polled with a generic request. For a stateless client, use McpAssured.injectStatelessMeta(request, clientCapabilities) to declare the extension on the raw request. |