Background 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 are a different feature: FrontMCP's own, started and read through ordinary tools (execute_job, get_job_status), so they work with any client.
@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.
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" }) {
// ...
}
}| 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.
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. |
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. |
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. |
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:
{
"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. |
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. |
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. |
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. 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
subof the caller's token. With a static key, that's derived from the key, so every client with the same key can read the others' tasks. Anyone else getsTask not found, the same as for an id that doesn't exist. this.fail()loses its message in a task. The task isfailedwitherror: { code: -32603, message: "" }. A thrownPublicMcpErrorkeeps 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.- The tool runs again from the top after
tasks/update, as an elicitation does. Put side effects after the lastthis.elicit(). - The tool's
authoritiesare checked before the task starts. A caller they refuse getsAUTHORITY_DENIEDin the call's answer, as for an inline call, and no task is created. This was checked on Node. - A
tasks/canceldoesn't stop a tool that ignoresthis.signal. The task iscancelledeither 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:
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") };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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. The tool decides what stopping means: here it stops between pages, and leaves the tickets it has already archived archived.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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() 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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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. |
| 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). 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 behind a load balancer keeps them in Redis, so a tasks/get or tasks/cancel can land anywhere:
@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.
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:
npm install @frontmcp/storage-sqliteimport { 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/getreported the taskfailed, withTask runner exited before completing the task. - A client before 2026-07-28 that cancelled got the process stopped with
SIGTERM: the tool'sthis.signalaborted, with the reasonSIGTERM. A 2026-07-28tasks/cancelmarked the taskcancelledbut didn't stop the process, which ran the tool to the end; its result was dropped. tasks/resultfrom a client before 2026-07-28 answered up topollIntervalMsafter 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:
[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 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 gets it. Put auth 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. 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 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.
// 🚩 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.
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 without Redis.