# Background tasks

> Let a slow tool run as an MCP task, so the client gets a task id at once, polls for the result, answers the tool's questions and can cancel it.

Source: https://frontmcp.dev/reference/server/tasks

A tool call holds its request open until the tool returns. For a tool that takes minutes, like exporting every ticket, that's a model waiting the whole time and a request a proxy may cut off. A tool with `execution.taskSupport` can run as a **task** instead: the client gets a task id straight away, the tool keeps running on the server, and the client asks `tasks/get` how it's going until the result is there. The client can answer questions the tool asks on the way, and cancel it.

Tasks are part of MCP, so the client has to support them. Under MCP 2026-07-28 they're the `io.modelcontextprotocol/tasks` extension, which a client declares on each request, and only a signed-in caller can start one. [Jobs](https://frontmcp.dev/learn/running-jobs-in-the-background) are a different feature: FrontMCP's own, started and read through ordinary tools (`execute_job`, `get_job_status`), so they work with any client.

```ts
@Tool({ name, description, inputSchema, execution: { taskSupport: "optional" } }) // or "required"

@FrontMcp({ info, apps, tasks?: { defaultTtlMs?, defaultPollIntervalMs?, redis?, sqlite?, runner? } })
```

---

## Reference

### `execution.taskSupport`

Set it on a tool to say whether it may run as a task. `tools/list` sends it as the tool's `execution`, so clients know before they call.

```ts export-tickets.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "export_tickets",
  description: "Export every ticket with a status as CSV. Slow: reads the database a page at a time.",
  inputSchema: { status: z.enum(["open", "closed"]) },
  execution: { taskSupport: "optional" },
})
export class ExportTickets extends ToolContext {
  async execute({ status }: { status: "open" | "closed" }) {
    // ...
  }
}
```

[See more examples below.](#usage)

| Value | A 2026-07-28 client that declares the tasks extension | A 2026-07-28 client that doesn't |
| --- | --- | --- |
| `"forbidden"`, or no `execution` | The tool runs inline. | The tool runs inline. |
| `"optional"` | Gets a task, every time. The client can't ask for an inline run instead. | The tool runs inline. |
| `"required"` | Gets a task. | `-32602` `Tool "…" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities`. |

Clients on protocol versions before 2026-07-28 choose per call instead: see [Clients on older protocol versions](#clients-on-older-protocol-versions).

The tool itself doesn't change. It runs as a task with the same input and the same caller (`this.auth`) as an inline call, and what it returns becomes the task's `result`.

### `tasks`

`@FrontMcp({ tasks })` configures where tasks are kept and how long. Nothing has to be set for tasks to work on a long-lived server.

| Option | Default | Description |
| --- | --- | --- |
| `enabled` | on | `false` turns tasks off. Tools with `taskSupport: "optional"` then always run inline, those with `"required"` fail with `-32601` `Tool "…" requires task-augmented invocation`, and `tasks/get` fails with `-32603` `Task store is not configured`. |
| `defaultTtlMs` | `3600000` (an hour) | How long a task is kept after it starts. Afterwards `tasks/get` answers `Task not found`. Under 2026-07-28 a client can't ask for another, so every task gets this. |
| `maxTtlMs` | `86400000` (a day) | The most an older client may ask for with `task.ttl`; a longer `ttl` is cut to this. It doesn't limit `defaultTtlMs`. |
| `defaultPollIntervalMs` | `2000` | Sent with every task as `pollIntervalMs`: how often the client should ask. |
| `keyPrefix` | `"mcp:task:"` | The prefix of every key in Redis. |
| `redis` | the server's `redis` | Keep tasks in Redis, so every instance can read and cancel them: `{ provider: "redis", host, port?, password?, db?, tls? }`. See [Keeping tasks in Redis](#keeping-tasks-in-redis). |
| `sqlite` | the server's `sqlite`, unless there's a `redis` | Keep tasks in a SQLite file: `{ path, encryption?: { secret }, walMode?, ttlCleanupIntervalMs?, busyTimeoutMs? }`. Needs `@frontmcp/storage-sqlite`. Without `encryption`, each task's input and result are in the file as plain JSON; with it, they're encrypted, and only the task id, owner, status and times stay readable. |
| `runner` | `"in-process"` | Where a task runs. `"cli"` starts a separate process for each task: see [Running each task in its own process](#running-each-task-in-its-own-process). |
| `cliRunnerCommand` | the command that started the server | With `runner: "cli"`, the command that starts a task's process: `{ exe, args? }`. |
| `strict` | `false` | On an edge runtime, refuse to start rather than accept tasks that can't run there. See [Where tasks can't run](#where-tasks-cant-run). |

Without `redis` or `sqlite`, here or at the top level, tasks are kept in the server's memory, and a restart loses them.

### A task

The call's answer, and every `tasks/get`, describe the task:

```json
{
  "taskId": "b6ce64e1-c3ee-419c-867f-3e3ffb325820",
  "status": "completed",
  "createdAt": "2026-10-10T11:43:22.953Z",
  "lastUpdatedAt": "2026-10-10T11:43:23.158Z",
  "ttlMs": 3600000,
  "pollIntervalMs": 2000,
  "result": { "content": [{ "type": "text", "text": "{\"rows\":1200}" }], "structuredContent": { "rows": 1200 } }
}
```

| Field | Description |
| --- | --- |
| `taskId` | A random UUID. Only the caller who started the task can use it. |
| `status` | `working`, `input_required`, `completed`, `failed` or `cancelled`. See [Statuses](#statuses). |
| `statusMessage` | A sentence about the status: `The operation is now in progress.`, `The task is waiting for additional input.`, `The task was cancelled by the client.`, or why it failed. |
| `createdAt`, `lastUpdatedAt` | ISO timestamps. |
| `ttlMs`, `pollIntervalMs` | From [`tasks`](#tasks). |
| `result` | Once `completed`: the `tools/call` result the tool would have returned inline. |
| `error` | Once `failed`: `{ code, message }`. |
| `inputRequests` | While `input_required`: what the tool is asking, in the same shape as an [elicitation](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels). |

The call that starts the task answers `{ resultType: "task", task }`, where a normal call answers `resultType: "complete"`.

#### Statuses

A task starts `working`. It ends `completed` when the tool returns, `failed` when it throws or fails, and `cancelled` when the client cancels it. Those three are final: nothing changes a task after that, not even the tool finishing later. A tool that calls `this.elicit()` moves the task to `input_required` until the client answers with `tasks/update`, and then back to `working`.

#### Methods

Under 2026-07-28, each of these needs the extension declared on the request, and the same caller that started the task:

| Method | Params | Answers |
| --- | --- | --- |
| `tasks/get` | `{ taskId }` | The task. |
| `tasks/update` | `{ taskId, inputResponses }` | `{}`, and the task goes back to `working`. Only while it's `input_required`. |
| `tasks/cancel` | `{ taskId }` | `{}`. The task is `cancelled`, and the tool's `this.signal` aborts. A task that has already ended stays as it was, and still gets `{}`. |

`tasks/result` and `tasks/list`, which older clients use, get `404` and `-32601` `Method not found: tasks/result was removed in protocol 2026-07-28`. 2026-07-28 sends no notification when a task's status changes: the client polls.

#### Caveats

- **Under 2026-07-28, only a signed-in caller can start or read a task.** The protocol has no sessions, and anonymous callers on a public server get a new id on every request, so FrontMCP would have no way to give a task back to the caller who started it. It refuses instead: see [the error](#tasks-require-an-authenticated-caller-under-protocol-2026-07-28). The Playground's own calls are anonymous, and don't declare the extension, so the tests on this page start a second server, behind a static key.
- **A task belongs to its caller:** the `sub` of the caller's token. With a [static key](https://frontmcp.dev/reference/auth/modes), that's derived from the key, so every client with the same key can read the others' tasks. Anyone else gets `Task not found`, the same as for an id that doesn't exist.
- **`this.fail()` loses its message in a task.** The task is `failed` with `error: { code: -32603, message: "" }`. A thrown `PublicMcpError` keeps its message (not its code), so throw it in a tool that runs as a task. See [A failed task's error has no message](#a-failed-tasks-error-has-no-message).
- **The tool runs again from the top after `tasks/update`,** as an [elicitation](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels) does. Put side effects after the last `this.elicit()`.
- **The tool's [`authorities`](https://frontmcp.dev/reference/auth/authorities) are checked before the task starts.** A caller they refuse gets `AUTHORITY_DENIED` in the call's answer, as for an inline call, and no task is created. This was checked on Node.
- A `tasks/cancel` doesn't stop a tool that ignores `this.signal`. The task is `cancelled` either way, and the tool's result is dropped.

---

## Usage

### Letting a tool run as a task

Give the tool `execution: { taskSupport: "optional" }`. A client that doesn't do tasks, like this Playground's, still gets the result inline: that's the Call tab. A client that declares the extension gets a task, and polls it. Open the **Tests** tab:

```ts export-tickets.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { readPage } from "./database";

@Tool({
  name: "export_tickets",
  description: "Export every ticket with a status as CSV. Slow: reads the database a page at a time.",
  inputSchema: { status: z.enum(["open", "closed"]) },
  execution: { taskSupport: "optional" },
})
export class ExportTickets extends ToolContext {
  async execute({ status }: { status: "open" | "closed" }) {
    const rows: string[] = [];
    for (let page = 1; page <= 4; page++) rows.push(...(await readPage(status, page)));
    return { status, rows: rows.length, csv: ["id,status", ...rows].join("\n") };
  }
}
```

```ts tasks.test.ts
import { test, expect } from "@frontmcp/testing";
import { client, serve } from "./client";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a client without the extension gets the result inline", async ({ mcp }) => {
  const result = await mcp.tools.call("export_tickets", { status: "open" });
  expect(result.json()).toMatchObject({ status: "open", rows: 4 });
});

test("with the extension, the call answers at once with a working task", async () => {
  const nour = client(await serve(), "key-nour");
  const started = await nour("tools/call", { name: "export_tickets", arguments: { status: "open" } });
  expect(started.result).toMatchObject({
    resultType: "task",
    task: { taskId: expect.any(String), status: "working", ttlMs: 3600000, pollIntervalMs: 100 },
  });
});

test("tasks/get says working, then completed with the result", async () => {
  const nour = client(await serve(), "key-nour");
  const { taskId } = (await nour("tools/call", { name: "export_tickets", arguments: { status: "open" } })).result.task;

  const during = (await nour("tasks/get", { taskId })).result;
  await wait(300);
  const after = (await nour("tasks/get", { taskId })).result;

  expect(during).toMatchObject({ taskId, status: "working", statusMessage: "The operation is now in progress." });
  expect(during.result).toBeUndefined();
  expect(after).toMatchObject({ status: "completed", result: { structuredContent: { status: "open", rows: 4 } } });
});

test("another caller can't read the task", async () => {
  const server = await serve();
  const { taskId } = (await client(server, "key-nour")("tools/call", { name: "export_tickets", arguments: { status: "open" } })).result.task;
  expect((await client(server, "key-sam")("tasks/get", { taskId })).error).toEqual({ code: -32602, message: "Task not found" });
});

test("an anonymous caller can't start one", async () => {
  const anonymous = client(await serve({ mode: "public" }));
  const refused = await anonymous("tools/call", { name: "export_tickets", arguments: { status: "open" } });
  expect(refused.error).toEqual({
    code: -32602,
    message:
      "Tasks require an authenticated caller under protocol 2026-07-28: there are no protocol sessions, so an anonymous task could not be scoped to its creator",
  });
});
```

```ts client.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

/** The help desk behind two static keys, so its callers are signed in. */
export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) {
  return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config);
}

/** A 2026-07-28 client that declares the tasks extension, sending `key` if there is one. */
export function client(server: Awaited<ReturnType<typeof serve>>, key?: string) {
  let id = 0;
  return async (method: string, params: Record<string, unknown> = {}) => {
    const response = await server(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": method,
          ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}),
          ...(key ? { authorization: `Bearer ${key}` } : {}),
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: ++id,
          method,
          params: {
            ...params,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } },
            },
          },
        }),
      }),
    );
    return response.json();
  };
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] })
class HelpDeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  tasks: { defaultPollIntervalMs: 100 },
};

@FrontMcp(config)
export default class Server {}
```

```ts database.ts
// Stands in for a slow database: 4 pages, 50 ms each.
export async function readPage(status: string, page: number) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  return [`T-${page},${status}`];
}
```

The call answered before the export had read a page. `main.ts` lowers `defaultPollIntervalMs`, the hint each task carries for how often to ask, to 100 ms; it's two seconds by default. The task keeps its result for `ttlMs`, an hour by default, so a client can come back for it later.

### Requiring a task

A tool that should never hold a request open sets `taskSupport: "required"`. A client that can't do tasks then gets an error instead of a long wait, which is what this Playground's client gets:

```ts archive.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "archive_closed_tickets",
  description: "Move every closed ticket to cold storage. Runs as a background task.",
  inputSchema: {},
  execution: { taskSupport: "required" },
})
export class ArchiveClosedTickets extends ToolContext {
  async execute() {
    await new Promise((resolve) => setTimeout(resolve, 100));
    return { archived: 340 };
  }
}
```

```ts required.test.ts
import { test, expect } from "@frontmcp/testing";
import { client, serve } from "./client";

test("a client without the extension is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("archive_closed_tickets", {});
  expect(result.error).toEqual({
    code: -32602,
    message: 'Tool "archive_closed_tickets" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities',
  });
});

test("a client with the extension gets a task", async () => {
  const nour = client(await serve(), "key-nour");
  const started = await nour("tools/call", { name: "archive_closed_tickets", arguments: {} });
  expect(started.result.task.status).toBe("working");
});

test("tools/list tells clients which tools run as tasks", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.execution).toEqual({ taskSupport: "required" });
});
```

```ts client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) {
  return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config);
}

export function client(server: Awaited<ReturnType<typeof serve>>, key?: string) {
  let id = 0;
  return async (method: string, params: Record<string, unknown> = {}) => {
    const response = await server(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": method,
          ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}),
          ...(key ? { authorization: `Bearer ${key}` } : {}),
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: ++id,
          method,
          params: {
            ...params,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } },
            },
          },
        }),
      }),
    );
    return response.json();
  };
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ArchiveClosedTickets } from "./archive.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [ArchiveClosedTickets] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

@FrontMcp(config)
export default class Server {}
```

`client.ts` is the same as in the first example.

### Cancelling a task

`tasks/cancel` marks the task `cancelled` and aborts the tool's [`this.signal`](https://frontmcp.dev/reference/sdk/contexts#thissignal). The tool decides what stopping means: here it stops between pages, and leaves the tickets it has already archived archived.

```ts archive.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";
import { archivePage, progress } from "./database";

@Tool({
  name: "archive_closed_tickets",
  description: "Move every closed ticket to cold storage. Runs as a background task.",
  inputSchema: {},
  execution: { taskSupport: "required" },
})
export class ArchiveClosedTickets extends ToolContext {
  async execute() {
    for (let page = 1; page <= 20; page++) {
      if (this.signal?.aborted) {
        progress.stoppedAt = page;
        break;
      }
      await archivePage(page);
    }
    return { pages: progress.archived };
  }
}
```

```ts cancel.test.ts
import { test, expect } from "@frontmcp/testing";
import { client, serve } from "./client";
import { progress } from "./database";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a cancelled task stays cancelled, and the tool stops", async () => {
  const nour = client(await serve(), "key-nour");
  const { taskId } = (await nour("tools/call", { name: "archive_closed_tickets", arguments: {} })).result.task;
  await wait(120);

  expect((await nour("tasks/cancel", { taskId })).result).toMatchObject({ resultType: "complete" });
  await wait(100);

  expect((await nour("tasks/get", { taskId })).result).toMatchObject({
    status: "cancelled",
    statusMessage: "The task was cancelled by the client.",
  });
  expect(progress.stoppedAt).toBeGreaterThan(1);
  expect(progress.stoppedAt).toBeLessThan(20);
});

test("cancelling a task that has ended changes nothing", async () => {
  const nour = client(await serve(), "key-nour");
  const { taskId } = (await nour("tools/call", { name: "archive_closed_tickets", arguments: {} })).result.task;
  await nour("tasks/cancel", { taskId });
  expect((await nour("tasks/cancel", { taskId })).error).toBeUndefined();
  expect((await nour("tasks/get", { taskId })).result.status).toBe("cancelled");
});
```

```ts database.ts
export const progress = { archived: 0, stoppedAt: 0 };

// Stands in for slow storage: each page takes 40 ms.
export async function archivePage(page: number) {
  await new Promise((resolve) => setTimeout(resolve, 40));
  progress.archived = page;
}
```

```ts client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) {
  return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config);
}

export function client(server: Awaited<ReturnType<typeof serve>>, key?: string) {
  let id = 0;
  return async (method: string, params: Record<string, unknown> = {}) => {
    const response = await server(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": method,
          ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}),
          ...(key ? { authorization: `Bearer ${key}` } : {}),
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: ++id,
          method,
          params: {
            ...params,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } },
            },
          },
        }),
      }),
    );
    return response.json();
  };
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ArchiveClosedTickets } from "./archive.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [ArchiveClosedTickets] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

@FrontMcp(config)
export default class Server {}
```

Pass `this.signal` on to whatever the tool waits for, like `this.fetch()` (which takes it already) or a database driver, so a cancel stops that too. A tool that never looks at the signal runs to the end; its task is still `cancelled`, and what it returns is dropped.

### Asking the user from a task

A tool that calls [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) in a task doesn't fail. The task waits in `input_required`, `tasks/get` shows the question in `inputRequests`, and the client answers it with `tasks/update`. The task then runs the tool again with the answer. The client has to declare `elicitation` too, as for any elicitation. The Call tab, which doesn't do tasks, shows the same question as a form:

```ts purge.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { runs } from "./database";

@Tool({
  name: "purge_closed_tickets",
  description: "Delete tickets closed more than a year ago, after the user confirms",
  inputSchema: {},
  execution: { taskSupport: "optional" },
})
export class PurgeClosedTickets extends ToolContext {
  async execute() {
    runs.count++;
    const answer = await this.elicit("Delete 340 tickets closed more than a year ago?", z.object({ confirm: z.boolean() }));
    if (answer.status !== "accept" || !answer.content?.confirm) return { deleted: 0 };
    return { deleted: 340 };
  }
}
```

```ts input.test.ts
import { test, expect } from "@frontmcp/testing";
import { client, serve } from "./client";
import { runs } from "./database";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the task waits for input, then finishes with the answer", async () => {
  const nour = client(await serve(), "key-nour");
  const { taskId } = (await nour("tools/call", { name: "purge_closed_tickets", arguments: {} })).result.task;
  await wait(50);

  const waiting = (await nour("tasks/get", { taskId })).result;
  expect(waiting).toMatchObject({
    status: "input_required",
    inputRequests: {
      "elicitation-1": { method: "elicitation/create", params: { message: "Delete 340 tickets closed more than a year ago?" } },
    },
  });

  const answer = { "elicitation-1": { action: "accept", content: { confirm: true } } };
  expect((await nour("tasks/update", { taskId, inputResponses: answer })).error).toBeUndefined();
  await wait(50);

  expect((await nour("tasks/get", { taskId })).result).toMatchObject({ status: "completed", result: { structuredContent: { deleted: 340 } } });
  expect(runs.count).toBe(2);
});

test("an answer for a task that isn't waiting is refused", async () => {
  const nour = client(await serve(), "key-nour");
  const { taskId } = (await nour("tools/call", { name: "purge_closed_tickets", arguments: {} })).result.task;
  await wait(50);
  const answer = { "elicitation-1": { action: "decline" } };
  await nour("tasks/update", { taskId, inputResponses: answer });
  await wait(50);
  expect((await nour("tasks/update", { taskId, inputResponses: answer })).error).toEqual({
    code: -32602,
    message: `Task ${taskId} is not awaiting input (status: completed)`,
  });
});
```

```ts client.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

export function serve(auth: object = { mode: "static", tokens: ["key-nour", "key-sam"] }) {
  return FrontMcpInstance.createFetchHandler({ ...config, auth } as typeof config);
}

/** As before, and the client also declares that it can show forms. */
export function client(server: Awaited<ReturnType<typeof serve>>, key?: string) {
  let id = 0;
  return async (method: string, params: Record<string, unknown> = {}) => {
    const response = await server(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "mcp-protocol-version": "2026-07-28",
          "mcp-method": method,
          ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}),
          ...(key ? { authorization: `Bearer ${key}` } : {}),
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: ++id,
          method,
          params: {
            ...params,
            _meta: {
              "io.modelcontextprotocol/protocolVersion": "2026-07-28",
              "io.modelcontextprotocol/clientCapabilities": {
                extensions: { "io.modelcontextprotocol/tasks": {} },
                elicitation: { form: {} },
              },
            },
          },
        }),
      }),
    );
    return response.json();
  };
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { PurgeClosedTickets } from "./purge.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [PurgeClosedTickets] })
class HelpDeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  elicitation: { enabled: true },
};

@FrontMcp(config)
export default class Server {}
```

```ts database.ts
export const runs = { count: 0 };
```

The tool ran twice: once up to the question, and once more, from the top, with the answer. A client that doesn't declare `elicitation` gets a `failed` task instead, with the message ``This request requires the `elicitation` client capability``.

### Calling a task tool from code

FrontMCP's own 2026-07-28 client, `McpStatelessClient` from `@frontmcp/sdk`, polls for you. When it declares the extension, `callTool()` gets the task, polls `tasks/get` every `pollIntervalMs`, and resolves with the task's `result`. It rejects when the task fails or is cancelled, and answers a task's questions with its `handlers`, as for [an inline elicitation](https://frontmcp.dev/reference/server/protocol-versions#calling-a-2026-07-28-server-from-code).

```ts stateless.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, McpStatelessClient } from "@frontmcp/sdk";
import { config } from "./main";

test("callTool() waits for the task, and resolves with its result", async () => {
  const server = await FrontMcpInstance.createFetchHandler({ ...config, auth: { mode: "static", tokens: ["key-nour"] } });
  const desk = new McpStatelessClient({
    url: "https://desk.example.com/",
    headers: { authorization: "Bearer key-nour" },
    capabilities: { extensions: { "io.modelcontextprotocol/tasks": {} } },
    // Hands each request to the server above, so this runs here. A real client leaves it out.
    fetchImpl: (url, init) => server(new Request(url, init)),
  });

  const result = await desk.callTool("export_tickets", { status: "closed" });
  expect(result.structuredContent).toMatchObject({ status: "closed", rows: 4 });
});
```

```ts export-tickets.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const readPage = async (status: string, page: number) => {
  await new Promise((resolve) => setTimeout(resolve, 50));
  return [`T-${page},${status}`];
};

@Tool({
  name: "export_tickets",
  description: "Export every ticket with a status as CSV. Slow: reads the database a page at a time.",
  inputSchema: { status: z.enum(["open", "closed"]) },
  execution: { taskSupport: "optional" },
})
export class ExportTickets extends ToolContext {
  async execute({ status }: { status: "open" | "closed" }) {
    const rows: string[] = [];
    for (let page = 1; page <= 4; page++) rows.push(...(await readPage(status, page)));
    return { status, rows: rows.length, csv: ["id,status", ...rows].join("\n") };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] })
class HelpDeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  tasks: { defaultPollIntervalMs: 100 },
};

@FrontMcp(config)
export default class Server {}
```

Against a real server, give it the server's `url` and leave out `fetchImpl`.

### Clients on older protocol versions

Clients on protocol versions before 2026-07-28 use the tasks of MCP 2025-11-25, inside their session. These were checked on a Node server, since the Playground speaks 2026-07-28 only:

| | Before 2026-07-28 | 2026-07-28 |
| --- | --- | --- |
| How the client asks | Per call: `task: { ttl? }` in the `tools/call` params. | Declares the extension; every call to an `"optional"` or `"required"` tool is a task. |
| Who may | Any caller with a session, anonymous ones included. The task belongs to the session. | Signed-in callers. The task belongs to the user or key. |
| The call's answer | `{ task, content: [], _meta: { "io.modelcontextprotocol/related-task": { taskId } } }`, with `ttl` and `pollInterval` | `{ resultType: "task", task }`, with `ttlMs` and `pollIntervalMs` |
| `ttl` | The client's, cut to `maxTtlMs`; `defaultTtlMs` when it asks for none. | Always `defaultTtlMs`. |
| `tasks/get` | The task, without its result. | The task, with `result` or `error` once it has ended. |
| `tasks/result` | Waits until the task ends, then answers what the call would have: the tool's result, or its JSON-RPC error. For a cancelled task, `-32603` `Terminal task has no outcome`. | Removed. |
| `tasks/list` | The session's tasks. | Removed. |
| `tasks/cancel` | Answers the cancelled task (`The task was cancelled by request.`). A task that has ended gets `-32602` `Cannot cancel task: already in terminal status '…'`. | Answers `{}`, whatever the status. |
| `tasks/update` | None. | Answers a task's `inputRequests`. |
| Status notifications | `notifications/tasks/status` on the session's `GET` stream, when the task starts and when it ends. | None. |
| Asking for a task on a `"forbidden"` tool | `-32601` `Tool "help-desk:search_tickets" does not support task-augmented invocation` | Runs inline. |
| Not asking on a `"required"` tool | `-32601` `Tool "help-desk:archive_closed_tickets" requires task-augmented invocation` | `-32602`, as [above](#executiontasksupport). |
| An unknown task, or another session's | `-32602` `Failed to retrieve task: Task not found` | `-32602` `Task not found` |

`initialize` advertises `capabilities.tasks` (`cancel`, `list`, `requests.tools.call`) when a tool sets `taskSupport`; `server/discover` always lists the extension (see [What `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns)). [Protocol versions](https://frontmcp.dev/reference/server/protocol-versions) covers how FrontMCP tells the two kinds of client apart.

### Keeping tasks in Redis

With tasks in memory, only the instance that started a task knows about it. A server that runs as [several instances](https://frontmcp.dev/reference/deployment/high-availability) behind a load balancer keeps them in Redis, so a `tasks/get` or `tasks/cancel` can land anywhere:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  redis: { host: process.env.REDIS_HOST!, port: 6379 }, // sessions, and tasks too
  // or tasks only: tasks: { redis: { provider: "redis", host: process.env.REDIS_HOST!, port: 6379 } },
})
export default class Server {}
```

The task still runs on the instance that started it. Checked with two Node servers sharing one Redis: a task started on the first was readable from the second, and a `tasks/cancel` sent to the second aborted the tool's `this.signal` on the first, through Redis pub/sub. Vercel KV can't do this: it has no pub/sub. With a Vercel KV `redis`, FrontMCP serves without tasks and logs ``[tasks] Background tasks are disabled: Vercel KV has no pub/sub``, unless `tasks.enabled` is `true`, which [refuses to start](#vercel-kv-is-not-supported-for-task-stores).

### Running each task in its own process

With the default `runner: "in-process"`, a task is a promise in the server's process: a restart loses it. `runner: "cli"` starts a separate Node process for each task instead, which runs the tool, writes the outcome to the store and exits. It needs a store both processes can read, a SQLite file or Redis:

```bash
npm install @frontmcp/storage-sqlite
```

```ts main.ts
import { homedir } from "node:os";
import { join } from "node:path";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  tasks: {
    runner: "cli",
    sqlite: { path: join(homedir(), ".help-desk", "tasks.db") }, // a full path: "~" isn't expanded
  },
})
export default class Server {}
```

The task's process runs the same file as the server, with `__run-task <taskId>` added to its arguments and the task's id in the `FRONTMCP_RUN_TASK_ID` environment variable, which makes `@FrontMcp` run that one task and exit instead of serving. To start it some other way, set `cliRunnerCommand`, like `{ exe: "node", args: ["dist/main.js"] }`: the arguments are added after `args`.

Checked on a Node server with `runner: "cli"` and a SQLite file:

- Each task ran in its own process, and the server answered while it did.
- A completed task could still be read after the server restarted.
- When a task's process was killed, the next `tasks/get` reported the task `failed`, with `Task runner exited before completing the task`.
- A client before 2026-07-28 that cancelled got the process stopped with `SIGTERM`: the tool's `this.signal` aborted, with the reason `SIGTERM`. **A 2026-07-28 `tasks/cancel` marked the task `cancelled` but didn't stop the process**, which ran the tool to the end; its result was dropped.
- `tasks/result` from a client before 2026-07-28 answered up to `pollIntervalMs` after the task ended, since a SQLite file can't tell the server the moment another process writes to it.

### Where tasks can't run

A task needs a process that keeps running after the response that started it. On an edge runtime, like Cloudflare Workers or Vercel Edge Functions, FrontMCP serves without tasks, and logs:

```text
[tasks] Background tasks are unavailable on this edge/serverless runtime: the in-process runner cannot continue past the HTTP response, and an in-memory store is not shared between isolates. Serving without tasks. Configure `tasks.redis` (Upstash Redis over HTTP works on Workers) to enable them.
```

Tools then behave as with `tasks.enabled: false`. With a `redis` (here or at the top level), or `tasks: { enabled: true }`, it sets tasks up anyway and warns that ``task bodies will not execute``; `tasks: { enabled: true }` without Redis doesn't start at all, with `Tasks require distributed storage on Edge runtime. Configure Redis or Upstash.` Add `strict: true` to refuse to start instead of warning. This was checked in Node by making FrontMCP detect an edge runtime (`EDGE_RUNTIME` and `VERCEL_ENV` set).

FrontMCP doesn't detect other serverless platforms. In an AWS Lambda function it sets tasks up as on any Node server, though the platform may freeze the function once the response is sent. There, use [jobs](https://frontmcp.dev/learn/running-jobs-in-the-background) with a store that outlives the function, or a queue of your own.

---

## Troubleshooting

### `Tasks require an authenticated caller under protocol 2026-07-28`

The full message goes on: `there are no protocol sessions, so an anonymous task could not be scoped to its creator`. A 2026-07-28 client that declared the tasks extension, and sent no credentials, called a tool with `taskSupport`, or sent `tasks/get`, `tasks/update` or `tasks/cancel`. The first example's [last test](#letting-a-tool-run-as-a-task) gets it. Put [`auth`](https://frontmcp.dev/reference/auth/modes) in front of the server so callers sign in, or keep the tool inline (`"forbidden"`, the default) for anonymous callers. A client that doesn't declare the extension gets an `"optional"` tool's result inline.

### `Tool "…" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities`

JSON-RPC error `-32602`. The tool has `taskSupport: "required"` and the client didn't declare the extension, as in [Requiring a task](#requiring-a-task). Use a client that supports tasks, or make the tool `"optional"`.

### `tasks/get requires the io.modelcontextprotocol/tasks extension`

HTTP `400` with JSON-RPC error `-32021`, and `data.requiredCapabilities` naming the extension. Under 2026-07-28, capabilities are per request: declare the extension on every `tasks/get`, `tasks/update` and `tasks/cancel`, not just on the call that started the task.

### `Task not found`

JSON-RPC error `-32602`. The id is wrong, the task has outlived its `ttlMs`, it's another caller's, or it was started on another instance and tasks are in memory. Keep them in [Redis](#keeping-tasks-in-redis) when the server runs as several instances.

### `Method not found: tasks/result was removed in protocol 2026-07-28`

HTTP `404` with `-32601`, for `tasks/result` or `tasks/list`. A 2026-07-28 client polls `tasks/get`, whose answer carries the `result` once the task has completed. There's no list: keep the ids you were given.

### `Task … is not awaiting input (status: …)`

JSON-RPC error `-32602` from `tasks/update`. The task isn't `input_required` any more: it was answered already, or it ended. Read it with `tasks/get` first.

### A failed task's error has no message

The tool called `this.fail()`, and the task's `error` is `{ code: -32603, message: "" }`. In 1.9.4 `this.fail()` loses its message when the tool runs as a task. Throw the error instead: a thrown `PublicMcpError` keeps its message.

```ts
// 🚩 In a task, the client gets an empty message
this.fail(new PublicMcpError("There are no closed tickets to export."));

// ✅ The message reaches the client
throw new PublicMcpError("There are no closed tickets to export.");
```

### `[tasks] runner: "cli" requires a persistent backend`

The server didn't start. The message goes on: ``(`tasks.sqlite`, `tasks.redis`, or a top-level `sqlite`) — the detached worker cannot share an in-memory store with this host.`` Give the tasks a SQLite file or Redis, as in [Running each task in its own process](#running-each-task-in-its-own-process).

### `Vercel KV is not supported for task stores`

The message goes on: `Task result blocking and cancel signalling require pub/sub. Use Redis or Upstash instead.` The server has `tasks: { enabled: true }` and its tasks would be in Vercel KV. Give them `tasks.redis` with Redis or Upstash, or remove `enabled: true` to serve without tasks.

### `Task store is not configured`

JSON-RPC error `-32603` from `tasks/get`, `tasks/update` or `tasks/cancel`: the server has `tasks: { enabled: false }`, or runs on an [edge runtime](#where-tasks-cant-run) without Redis.
