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 tool answers immediately with a task id, the work runs on QStash or Upstash Workflow so it survives restarts and the client’s tool timeout, and the task record lives in Upstash Redis. The model checks on it with a shared task_status tool and can stop it with task_cancel.

These are ordinary MCP tools, so they work in every client today: Claude Code, Codex, Cursor, OpenCode and ChatGPT. No client capability is required. The toolkit sits on top of the official MCP TypeScript SDK and uses WebCrypto only, so it runs on Node and edge runtimes.

Quickstart#

Four files in a Next.js app.

1. Identify the caller#

Every task belongs to the user who started it. Two functions, kept in one file so the path is easy to follow:

  • verifyToken runs on every MCP request. It verifies the bearer token and returns the SDK’s AuthInfo, with your user id in extra. Step 3 wires it up with withMcpAuth.
  • principal is what the toolkit calls. It gets that AuthInfo and returns the user id, or throws.
lib/auth.ts

2. Define the task layer and the task#

lib/tasks.ts

The handler runs later, in /api/execute, when QStash delivers the task, not inside the tool call. topic is typed from inputSchema: Zod, Valibot and ArkType schemas all work.

3. Serve the MCP endpoint#

app/api/mcp/route.ts

withMcpAuth hands the AuthInfo from verifyToken to the MCP server, and the toolkit passes it to principal. The task layer only registers tools, so nothing else changes in your route.

4. Add the execute endpoint#

app/api/execute/route.ts

This is where QStash delivers each task. The handler verifies the QStash signature, runs the task’s handler and stores the result.

Environment variables#

.env
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 use APP_URL=http://127.0.0.1:3000.

What the model sees#

tools/list now has generate_report (starts the task and returns its id), task_status (shows progress, then the handler’s result) and task_cancel.

Example responses

The states are working, completed, failed and cancelled; the last three are final. The task object has the same shape as the one in the MCP Tasks extension.

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 the layer).

Cancellation#

task_cancel marks the task cancelled and stops any pending delivery. Cancelling is cooperative, so running code only stops where it checks task.isCancelled():

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.

Users#

Each task records who started it, and task_status / task_cancel only answer for that caller. Another user’s task id reads exactly like an unknown one, so nobody can tell it exists.

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. If Alice starts a task with Bob’s workspaceId, the task is hers, she can read its result, and the handler acted on Bob’s workspace.

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:

lib/tasks.ts

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 later, in a delivery request that carries no user auth, so task.principal is how it knows who it is working for.

Where principal comes from#

principal receives { auth, request } and may be async. auth is exactly what verifyToken returned in step 1: withMcpAuth runs it, and the toolkit hands the result to principal. Nothing is read from request headers on its own. Without mcp-handler, pass the AuthInfo yourself with the SDK’s createMcpHandler: handler.fetch(request, { authInfo }).

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 tool 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.

  • principal also receives request, for cookie or session apps. It is unverified, so check the session yourself, and never trust a header like x-user-id:

  • A server with no users of its own passes principal: () => "local".

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 routes stay the same. The handler’s task then also has run, sleep and call, inferred from the dispatcher:

lib/tasks.ts
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.

QStashDispatcherWorkflowDispatcher
Survives the process dyingyes (QStash redelivers)yes (replayed from the journal)
Can run longer than one invocationnoyes, one invocation per step
On a retrythe whole task starts overonly the failed step runs again
Cancel stops a running taskat its next isCancelledthe run itself is cancelled

Retries and failures#

How deliveries are verified and retried
  • The execute route 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: true 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. This holds for WorkflowDispatcher too. Without signing keys (or a receiver you pass), the route throws on its first request instead of running anything unverified.
  • 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.
  • The model only sees a failure’s 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 logged, never stored or returned.
  • By default QStash tries 5 times 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 and task_cancel only accept UUID task ids, and compare the stored owner with the caller. Task ids are random UUIDs. No tool lists tasks, and the execute route 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.

  • args and result are plain JSON. Keep secrets out of tool arguments and results, 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.

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

Options#

All options

createTaskLayer: store, dispatcher and principal are required. Optional: defaults.ttlMs (1 day) and defaults.pollIntervalMs (2 seconds), in positive whole milliseconds.

tasks.define(name, config, handler): description and inputSchema are required. Optional: title, completedMessage (defaults to "Completed"), authorize(args, { principal, auth, request }).

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

Why tools, not the Tasks extension?#

The 2026-07-28 MCP specification defines a Tasks extension where the client polls, so the model spends no turns waiting. But a server may only return a task to a client that declared the extension, and as of October 2026 Claude Code, Codex, Cursor and OpenCode don’t. Plain tools cost the model a few polling calls but work everywhere today.

The store and dispatcher don’t depend on the tools, so an adapter for the extension can serve the same records once clients support it. Also not implemented yet: input_required (a handler asking the user something partway through a task) and listing tasks.

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.