
# Tasks

> Hand back a handle instead of blocking, and let the client poll.

The tasks extension (`io.modelcontextprotocol/tasks`, [SEP-2663](https://modelcontextprotocol.io/seps/2663-tasks-extension)) 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`.

```ts
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`](/protocols/modern/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:

```ts
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"`:

```json
{
  "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](/protocols/modern/caching) — 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:

```ts
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.

```ts
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 {#tasks-update}

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`:

```ts
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](/protocols/modern/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`](/protocols/modern/subscriptions) stream:

```json
{ "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:

```json
{
  "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.

```ts
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](/protocols/modern/headers), 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.
