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

StatusTerminalCarries
workingnostatus and metadata only
input_requirednoinputRequests — every outstanding request
completedyesresult — what the tool returned
failedyeserror — a JSON-RPC error
cancelledyesstatus 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.

h3-mcp  MCP servers, built on H3.