Tasks
Hand back a handle instead of blocking, and let the client poll.
The tasks extension (io.modelcontextprotocol/tasks, SEP-2663) lets a tools/call answer with a task handle rather than a result. The work continues in the background; the client polls tasks/get with the returned taskId until the task reaches a terminal status, then reads the original result inlined under result.
import { defineMcpHandler, defineTool } from "h3-mcp";
import { canCreateTask, mcpTask, mcpTasks } from "h3-mcp/tasks";
const renderTool = defineTool({
name: "render",
execution: { taskSupport: "optional" },
handler: (event) => {
// The server decides per request — and MUST NOT hand a task to a client
// that did not declare the extension.
if (!canCreateTask(event)) {
return renderSynchronously();
}
return mcpTask(event, {
statusMessage: "Queued",
async run(task) {
for (let frame = 1; frame <= 100; frame++) {
await renderFrame(frame, task.signal);
task.update({ statusMessage: `Frame ${frame}/100` });
}
return { content: [{ type: "text", text: "Rendered." }] };
},
});
},
});
export default defineMcpHandler(
{ name: "my-server", version: "1.0.0", tools: [renderTool] },
// Installing the plugin is what serves the extension — and it carries its own
// configuration, so there is no handler option to keep in sync.
{ extensionPlugins: [mcpTasks()] },
);mcpTasks() is the whole switch. Nothing is pre-installed on any entrypoint, so a server that never serves tasks does not pay for them; installing the plugin advertises the identifier under capabilities.extensions and serves tasks/get, tasks/update and tasks/cancel. There is no tasks handler option — an extension owns its own options — so "configured but not installed" is not a state you can get into.
Gating is the plugin's own enabled option, and it accepts a per-request predicate:
defineMcpHandler(options, { extensionPlugins: [mcpTasks({ enabled: (event) => isBeta(event) })] });A request the predicate rejects serves no tasks/* method (-32601), agrees to no notifications/tasks subscription, and makes mcpTask throw. The server/discover advertisement follows the predicate too when your handler options are themselves a function of the event (defineMcpHandler((event) => ({ … }), …)); with static options the identifier is advertised once at mount and the per-request verdict is what refuses.
#The handle
mcpTask returns a flat Result & Task — there is no nested task wrapper, and resultType is "task" rather than "complete":
{
"resultType": "task",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"statusMessage": "Queued",
"createdAt": "2026-07-28T10:30:00.000Z",
"lastUpdatedAt": "2026-07-28T10:30:00.000Z",
"ttlMs": 600000,
"pollIntervalMs": 1000
}The handle is in the store before it is returned, so a tasks/get issued the instant the client sees the taskId resolves rather than racing creation.
ttlMs here is the task's lifetime, not a caching hint — a task result carries no cacheScope and is never cacheable.
#Statuses
| Status | Terminal | Carries |
|---|---|---|
working | no | status and metadata only |
input_required | no | inputRequests — every outstanding request |
completed | yes | result — what the tool returned |
failed | yes | error — a JSON-RPC error |
cancelled | yes | status and metadata only |
failed is only for a protocol error. A tool that ran and reported a problem is completed with isError: true in its inlined result — the same split tools/call makes synchronously:
mcpTask(event, {
async run() {
throw new Error("upstream refused"); // → completed, result.isError
},
});
mcpTask(event, {
async run() {
throw new McpJsonRpcError(-32603, "API rate limit exceeded"); // → failed
},
});#Cancellation
tasks/cancel answers an empty acknowledgement — no task envelope — and aborts task.signal. Observing the resulting status is a separate tasks/get.
Cancellation is cooperative, so honor the signal: work that ignores it keeps running against a task the client has already written off.
async run(task) {
while (!task.signal.aborted) {
await step();
}
return { content: [] };
}Cancelling an already-terminal task returns the same empty ack. -32602 is reserved for a task ID the server does not recognize.
notifications/cancelled is not the mechanism for tasks and must not be used for one.
#Asking for input mid-flight
A task that needs the user can park itself. task.requestInput moves it to input_required, surfaces the requests on every tasks/get, and resolves once the client has answered all of them through tasks/update:
import { mcpElicit, mcpTask } from "h3-mcp";
const askConfirm = mcpElicit({
message: "Really delete everything?",
requestedSchema: {
type: "object",
properties: { confirm: { type: "boolean" } },
required: ["confirm"],
},
});
mcpTask(event, {
async run(task) {
const answers = await task.requestInput({ confirm: askConfirm });
const ok = answers.confirm?.action === "accept" && answers.confirm.content?.confirm;
return { content: [{ type: "text", text: ok ? "Deleted." : "Cancelled." }] };
},
});Each answer is typed by the schema that asked for it, exactly as in MRTR — and, exactly as there, the values are unvalidated client input.
Ask for several at once and the client may answer them one at a time: each tasks/update is acked, the task stays parked, and only the answered keys disappear from the next tasks/get. Keys the server never issued, or already consumed, are ignored rather than rejected. A key asked twice within one task is given a fresh wire name (confirm, then confirm.2) so an answer can never be attributed to the wrong round.
Note
This is not MRTR. MRTR gathers input before a result exists, by having the client retry the original request; a task gathers it during execution, through tasks/update. The two compose: run the MRTR rounds first, then return a task on the last one. Their keys are independent.
#Status notifications
A client can skip polling by subscribing on a subscriptions/listen stream:
{ "method": "subscriptions/listen", "params": { "notifications": { "taskIds": ["786512e2-…"] } } }The server echoes the IDs it agreed to in the acknowledgement, then pushes a notifications/tasks frame carrying the complete task — identical to what tasks/get would have answered at that moment — on every status change. Asking for task notifications without declaring the extension is -32021.
notifications/progress and notifications/message are never delivered for a task; statusMessage is the progress channel.
#Tools that require a task
execution: { taskSupport: "required" } says the tool cannot be served synchronously at all. A tools/call from a client that did not declare the extension is rejected with -32021 before the handler runs, naming what to declare:
{
"code": -32021,
"message": "Missing required client capability",
"data": { "requiredCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } }
}"optional" and "forbidden" are advisory only — they are advertised in tools/list and never change dispatch, because the server alone decides per request whether to answer with a task.
#Retention and durability
Tasks live in a bounded in-memory store, swept lazily on access — no reaper timer.
mcpTasks({
max: 1000, // tasks retained at once; creation fails past it
ttlMs: 600_000, // lifetime from creation; null for unlimited
pollIntervalMs: 1000, // advertised to clients
bind: (event) => event.context.auth?.userId ?? throwUnauthenticated(),
});A task stays retrievable for at least its ttlMs; past that it reads as unknown, which is what the spec says a purged task looks like. At the cap, creation fails rather than dropping a handle a client may still be polling.
bind scopes a task to its caller: the value is computed at creation and re-checked on every later request, and a mismatch is reported exactly like an unknown task — so a leaked task ID is useless to a third party. Task IDs are UUIDv4 (122 bits of entropy) precisely because they may otherwise be the only thing standing between callers.
Important
The store is in-process. A task is driven by the run callback of the handler that created it, so handles do not survive a restart and are not shared between instances. For work that outlives the process, poll the external system from inside run and keep the client pinned to this instance — the Mcp-Name: <taskId> routing header, which clients MUST send on every tasks/* request, exists for exactly that.
#Not the 2025-11-25 tasks utility
The experimental tasks feature in 2025-11-25 is a different, superseded wire shape — ttl/pollInterval, a nested result.task envelope, tasks/result and tasks/list, a client-sent task hint, and an error rather than an ack when cancelling a terminal task. h3-mcp does not implement it: a legacy client sees no task capability, and its params.task hint is ignored rather than rejected. tasks/result and tasks/list answer -32601 on the modern era, as the extension requires.