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:
verifyTokenruns on every MCP request. It verifies the bearer token and returns the SDK’sAuthInfo, with your user id inextra. Step 3 wires it up withwithMcpAuth.principalis what the toolkit calls. It gets thatAuthInfoand returns the user id, or throws.
2. Define the task layer and the task#
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#
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#
This is where QStash delivers each task. The handler verifies the QStash signature, runs the task’s handler and stores the result.
Environment variables#
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.
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:
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
principalthrows, 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. -
principalalso receivesrequest, for cookie or session apps. It is unverified, so check the session yourself, and never trust a header likex-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:
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.runcalls. - 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.
QStashDispatcher | WorkflowDispatcher | |
|---|---|---|
| Survives the process dying | yes (QStash redelivers) | yes (replayed from the journal) |
| Can run longer than one invocation | no | yes, one invocation per step |
| On a retry | the whole task starts over | only the failed step runs again |
| Cancel stops a running task | at its next isCancelled | the 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: truewhen 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 forWorkflowDispatchertoo. Without signing keys (or areceiveryou 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 isfailedat once, with no retry, andtask_statusreturns your content withisError, as the synchronous tool would. - The model only sees a failure’s
error.codeanderror.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.
argsandresultare plain JSON. Keep secrets out of tool arguments and results, or use a shortttlMs.- 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: falseis 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.