Skip to main content

An MCP tool call is a request that waits for its response. When the work takes minutes, something gives up first: the client’s tool timeout, your serverless function’s limit, or the model.

@upstash/mcp-toolkit/tasks turns a slow tool into a task: the call returns at once and the work carries on in the background. Clients that support the MCP Tasks extension (Claude Code) get a native task, which they poll and show themselves. Every other client gets a task id in an ordinary tool result, and the model checks on it with the task_status and task_cancel tools.

Tasks run in memory with nothing else to set up. Add Upstash Redis and QStash (or Upstash Workflow) to make them durable. The toolkit sits on top of the official MCP TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes.

An MCP server with a slow tool#

handler is a Web-standard (request: Request) => Promise<Response>, so it mounts in any framework, or in Node through an adapter such as @hono/node-server:

That is the whole setup: no database, no queue and no other endpoint. topic is typed from inputSchema (Zod, Valibot and ArkType schemas all work). The catch is that the tasks live in this process. See Make it durable before you deploy to serverless or run more than one instance.

What the client sees#

tools/list has generate_report, task_status and task_cancel, and the server advertises the Tasks extension (io.modelcontextprotocol/tasks, protocol revision 2026-07-28). On every call to generate_report, McpTasks checks whether the request declares the extension:

  • A client that declares it gets a native task (resultType: "task"). The client polls tasks/get itself, shows the progress, and hands the model the handler’s result when it is done, so the model spends no turns polling. tasks/cancel stops it. In Claude Code, the call returns at once and Claude keeps working; the footer shows 1 MCP task while it runs, and the result arrives in the conversation when the task completes. See MCP Tasks for server authors.
  • Any other client gets the task id in an ordinary tool result. The model polls task_status (progress, then the handler’s result) and can stop the task with task_cancel.

Both read the same task record.

Which clients declare the extension:

  • Claude Code CLI v2.1.304 and later, on calls from the main conversation in interactive terminal sessions, and in Agent SDK and GitHub Actions runs with CLAUDE_AUTO_BACKGROUND_TASKS=1. It does not on calls from subagents, in one-shot claude -p runs, in the VS Code extension or the desktop app, or on third-party providers such as Bedrock; those calls get the tool-based handle.
  • Codex, Cursor, OpenCode and ChatGPT don’t declare it (as of October 2026) and get the tool-based handle.

task_status and task_cancel stay listed alongside native tasks, because they are the fallback and one client can send both kinds of call. Claude Code shows the model a native task’s id, so the model may call task_status on it too; both paths give the same answer.

Native task responses

A failed task carries a JSON-RPC error. An unknown, expired or malformed task id, another user’s task, and an unidentified caller all get the same -32602 error, so ids can’t be probed.

Tool-based responses

The states are working, input_required, completed, failed and cancelled; the last three are final. If the model stops polling, nothing is lost: the work finishes anyway, and the result can be read until the task expires (1 day by default; set defaults: { ttlMs } on McpTasks).

Make it durable with Redis and QStash#

In memory, a task is lost when the process restarts, and it only exists on the instance that took the call. On serverless, or behind several instances, the next tasks/get may reach an instance that never saw the task, and work left running after the response can be frozen or killed with the function. Keep the records in Upstash Redis and deliver the work through QStash instead: the record is shared by every instance, QStash retries a failed run, and each run gets a fresh invocation.

Pass a store and a dispatcher; registerTask and the MCP handler stay exactly as they were:

mcp.ts

The execute endpoint is another (request: Request) => Promise<Response>, mounted like the MCP one, at the URL you gave the dispatcher. It verifies the QStash signature, runs the task’s handler and stores the result. Every instance that serves it must import the module that calls tasks.registerTask(...), so it knows the handlers.

The Redis store and the QStash dispatcher read their credentials from the environment:

.env

For work that can outlast one function invocation, use WorkflowDispatcher (same url, same endpoint): each step runs in its own invocation, so a task has no overall time limit. See Work longer than one function invocation.

Note

For local development, run the QStash dev server with npx @upstash/qstash-cli dev, set QSTASH_URL=http://127.0.0.1:8080 and the credentials it prints, and point APP_URL at your local server, for example http://127.0.0.1:3000.

in memory (default)QStashDispatcherWorkflowDispatcher
NeedsnothingRedis, QStash, an endpointRedis, QStash, an endpoint
Serverless, several instancesnoyesyes
Survives the process dyingnoyes (QStash redelivers)yes (replayed from the journal)
Can run longer than one invocationn/a: as long as the processnoyes, one invocation per step
When the handler throwsfailed, no retrythe whole task starts overonly the failed step runs again
Ask the user (task.elicit)yesnoyes

In production (NODE_ENV=production), the in-memory defaults log a one-time warning. If a single long-lived server is what you want, pass them explicitly to say so: new McpTasks({ store: new MemoryTaskStore(), dispatcher: new InProcessDispatcher(), principal }) (both from @upstash/mcp-toolkit/tasks). await dispatcher.drain() on shutdown lets running tasks finish.

Users#

principal is a function that returns the id of the user making the call. Each task records who started it, and task_status, task_cancel, tasks/get and tasks/cancel only answer for that caller; another user’s task id reads exactly like an unknown one. () => "local" is for a server with no users of its own. Otherwise, verify the token and hand the result to the SDK, which passes it to principal:

auth.ts
mcp.ts

mcp-handler is Vercel’s adapter that serves an MCP server as a route handler in Next.js and other Fetch-based frameworks. If you serve the server with it, its withMcpAuth checks the bearer token for you, so you don’t write the handler above:

mcp.ts
More on principal
  • A throw refuses the call. There is no anonymous mode: if principal throws, rejects, or returns anything but a non-empty string, the call is refused as not authenticated.
  • Use the user id, not auth.clientId. The client id identifies the OAuth app, and every ChatGPT user shares the same one.
  • auth is exactly the authInfo your handler passed to fetch. Nothing is read from headers on its own.
  • principal also receives request, for cookie or session apps (Caller is { auth, request }), and may be async. request is unverified, so check the session yourself, and never trust a header like x-user-id.

Check what the arguments point at#

Owning the task is not the same as being allowed to touch what it works on. The model fills in the tool arguments, so a workspaceId in them is whatever it was told. Give the task an authorize, which runs when the tool is called, before anything is stored or queued, and use task.principal (the user who started the task) inside the handler. Never take a user id from the arguments:

authorize(args, { principal, auth, request }) gets the validated arguments, the caller’s id and the same auth and request as principal. It is the only place a task sees the caller’s token: the handler runs after the tool call has answered (with QStash, in a delivery request that carries no user auth), so task.principal is how it knows who it is working for.

Errors and limits. When authorize or a store throws, the caller sees only Internal error. Try again later. (a tool error, or -32603 on tasks/* and events/*) and the real error goes to console.error, so a database host or credential in it never reaches the model. Each user may have at most 100 tasks working or waiting for input (maxActiveTasks), and a task’s arguments may be at most 64 KiB of JSON (maxArgsBytes); past either, the call is refused before anything is stored. A task waiting for an answer counts as active until it is answered, cancelled or expires.

Cancellation#

task_cancel and tasks/cancel mark the task cancelled. With Workflow the run is cancelled too; with QStash a later redelivery finds the task settled and does nothing. Cancelling is cooperative, so running code only stops where it checks await task.isCancelled(), as the loop in the first example does between steps. isCancelled() also returns true once the task has expired.

Warning

Arguments are validated when the tool is called, then stored as JSON and handed to the handler later. Values that don’t survive JSON, such as a Date produced by z.coerce.date(), arrive as strings. Keep task schemas to plain values, or parse them again inside the handler.

Ask the user from a task#

A task can stop partway, ask the user a question, and carry on with the answer. MCP calls this elicitation: the client shows the user a form built from a schema and sends back what they entered.

answer.action is "accept" (with content), "decline" or "cancel". While it waits, the task is input_required; the client answers with tasks/update and the handler carries on.

  • Backends. Works in memory and with WorkflowDispatcher (which waits without holding a function open; call elicit between steps, not inside task.run). Not with QStashDispatcher: there, task has no elicit in the types (a plain-JS call fails the task).
  • Claude Code shows the form in interactive terminal sessions. In headless sessions it can’t, so it cancels the task.
  • Clients without native tasks get the question from task_status if they support elicitation on a 2026-07-28 connection. Otherwise, including on 2025-era connections (the default in many clients, the official SDK client 2.3.1 among them), the model is told the task is waiting and that only the user can answer. There is no tool for the model to answer in their place.
  • If the task is cancelled or expires while waiting, elicit throws; don’t catch that error. A task asks at most 16 questions, one at a time.

Tool-based handle for every client#

To answer every client with the tool-based handle, set mode: "tools". The server then serves no tasks/* methods and doesn’t advertise the extension:

Use it when something depends on the task id appearing in the tool result, such as a client integration or test that reads structuredContent.taskId, or when you want every client to see the same tools and responses.

Work longer than one function invocation#

With QStashDispatcher, the whole handler runs in one serverless invocation. If it goes past your platform’s time limit, it is killed, and the retry starts the handler from the beginning.

WorkflowDispatcher runs each step in its own invocation and replays finished steps from a journal, so a task has no overall time limit. Only the dispatcher changes; the endpoints stay the same. The handler’s task then also has run, sleep and call:

Rules for Workflow handlers

The handler runs again from the top on every step, and finished steps are replayed from the journal:

  • Put the work inside task.run. That makes it run once and survive a crash.
  • task.update(...) does not need wrapping.
  • Keep task.isCancelled() outside steps. It has to run again each time, or a later cancel is never seen.
  • Don’t nest task.run calls.
  • Each step must still fit within your function’s time limit.

The task’s TTL starts when the task is created and is never extended. When it runs out, the record is deleted and isCancelled() returns true. The default is 1 day; set ttlMs higher for work that can take longer.

With WorkflowDispatcher, cancelling a task also cancels its run; with QStash, a running task stops at its next isCancelled().

Retries and failures#

How tasks fail, and how deliveries are verified and retried
  • In memory (InProcessDispatcher) there are no retries: when your handler throws, the task is failed at once, with the message “The task handler threw an error”. What it threw is logged and kept in the record’s error.data, never shown to the model.
  • The execute endpoint answers 200 when the task ran (or had already finished), 500 when your handler threw, so QStash tries again, and 489 with Upstash-NonRetryable-Error when the signature or body is bad.
  • Every delivery’s QStash signature is checked against the URL you gave the dispatcher, not request.url, so it works behind a proxy and a signature issued for another endpoint is refused. Without signing keys (or a receiver you pass), the route throws on its first request instead of running anything unverified.
  • With QStash or Workflow, when your handler throws, the task is not marked failed. Only the dispatcher marks it failed, and only after QStash has stopped retrying. The failed message stays in the QStash DLQ.
  • When your handler returns a tool error (isError: true), the task is failed at once, with no retry, and task_status returns your content with isError, as the synchronous tool would.
  • What a failure looks like to the model is error.code and error.message. The transport’s details (error.data: the QStash DLQ id, the Workflow run id) stay in Redis for you. What your handler threw and a dispatch error are never returned. QStash and Workflow only log them; the in-memory dispatcher also keeps the message in error.data.
  • By default QStash retries 5 times (6 attempts in total) with backoff min(pow(3, retried) * 1000, 300000), about two minutes in total, so a task survives a server restart. The free tier and the local dev server allow at most 5 retries.

Security and storage#

Who can see what

The server sets a task’s owner from principal, and no tool argument can set it. Whether a caller may start a task with given arguments is your authorize; the handler gets the owner as task.principal. task_status, task_cancel, tasks/get and tasks/cancel only accept UUID task ids, and compare the stored owner with the caller. For anyone else, a task looks exactly like an unknown id (a tool error for the tools, -32602 for tasks/*). Task ids are random UUIDs. Nothing lists tasks, and the execute endpoint only accepts signed QStash deliveries and never returns a task.

Anyone with write access to your Redis can change an owner, so treat the Redis credentials like any other production secret.

What is stored in Redis

mcp:task:<taskId> is a hash with one JSON-encoded field per property: taskId, name, args, owner, status, statusMessage, result / error, createdAt, lastUpdatedAt, ttlMs, pollIntervalMs, and, for a task that asks the user, inputRequests (the open question) and one response:<key> field per answer.

  • args, result and the user’s answers are plain JSON. Keep secrets out of tool arguments, results and forms, or use a short ttlMs.
  • The task is written with its TTL before the tool replies, because the model’s next poll may reach another instance. The TTL counts from creation and is never extended.
  • Status changes go through one guarded Lua script, and the first final status wins, so a completion can’t overwrite a cancel. Each property is its own field, so a progress update and a cancel never overwrite each other.
  • A Redis client built with automaticDeserialization: false is not supported.

mcp:task:active:<owner> is a sorted set of the owner’s task ids, scored by expiry, for maxActiveTasks. Counting drops the expired and finished ones.

A task message in QStash carries only { taskId }. The toolkit never stores the caller’s token or the request.

Options#

All options

new McpTasks(options): principal is required. Optional: store (a MemoryTaskStore by default) and dispatcher (an InProcessDispatcher by default; a dispatcher needs a store with it), mode ("native", or "tools" for the tool-based handle only), maxActiveTasks (100 per user working or input_required; Infinity turns it off), maxArgsBytes (64 KiB of JSON), defaults.ttlMs (1 day) and defaults.pollIntervalMs (2 seconds), in positive whole milliseconds.

tasks.registerTask(name, config, handler): description and inputSchema are required. Optional: title, completedMessage (defaults to "Completed"), authorize(args, { principal, auth, request }). Call it at module scope. tasks.attach(server) adds the task tools to a server, and tasks.createExecuteHandler() is the dispatcher’s endpoint.

RedisTaskStore: redis (defaults to one from env), prefix (mcp:task:), enableTelemetry.

QStashDispatcher: url is required. Optional: qstash, receiver, retries (5), retryDelay, enableTelemetry.

WorkflowDispatcher: url is required. Optional: client, qstash, receiver, retries, enableTelemetry.

receiver defaults to one built from the QSTASH_*_SIGNING_KEY variables. The Redis and QStash clients get @upstash/mcp-toolkit@<version> added to their Upstash-Telemetry-Sdk header; turn it off with enableTelemetry: false or UPSTASH_DISABLE_TELEMETRY.

Custom backends

@upstash/mcp-toolkit/tasks doesn’t import anything from Upstash. A backend implements one of these interfaces (types exported from the same entry point). MemoryTaskStore and InProcessDispatcher are small examples:

Not supported yet#

  • Asking the user with QStashDispatcher (use WorkflowDispatcher), and input requests other than form elicitation, such as URL elicitation.
  • Listing tasks: tasks/list and notifications/tasks.
  • tasks/result. A completed task’s result comes with tasks/get.

Example#

The MCP toolkit demo is a Next.js app that runs the same tool on QStash and on Workflow, with a live log of every MCP request.