Add @upstash/mcp-tasks: a durable MCP Tasks runtime - #38
Open
CahidArda wants to merge 11 commits into
Open
Conversation
The 2026-07-28 MCP spec made the protocol stateless and moved long-running
tools to the Tasks extension, but the official TypeScript SDK v2 ships the
wire schemas with no runtime behind them. This adds one.
`createTaskLayer({ store, dispatcher })` turns a tool into a task-returning
tool and serves `tasks/get` / `tasks/cancel`, over two swappable interfaces:
a `TaskStore` for the record and a `TaskDispatcher` for the execution. The
split is the point — a durable task id does not make the work durable.
`@upstash/mcp-tasks/upstash` implements both on Upstash Redis (one hash per
task, PEXPIRE for TTL) and QStash (durable at-least-once delivery to an
execute endpoint), so a process killed mid-task still finishes the work.
Notable behaviour, all verified against the real SDK, real Redis and real
QStash rather than inferred:
- Terminal transitions go through a guarded, atomic `settle` (a Lua script on
Redis), so a client's cancel and the executor completing cannot clobber each
other; first terminal write wins.
- The store keeps one hash field per property, not one JSON blob, so a
progress update and a cancel never overwrite each other's fields.
- `executeTask(id, { isFinalAttempt })` keeps a task `working` until the
dispatcher's last delivery. Settling `failed` on the first error makes it
terminal and silently turns every retry into a no-op.
- The QStash retry delay defaults to exponential backoff. A flat 1s delay
exhausts five retries in ~10s, which a restart outlives — the task then
dead-letters while still reading `working` (observed, then fixed).
- Clients resolve on first use, not in the constructor, so a Next.js
production build that imports route modules without credentials still
builds.
Two SDK gotchas are documented and worked around: `createMcpHandler` answers
`tasks/*` with -32601 before reaching a handler (hence the transport-based
route, plus a `methods` option to namespace them), and `McpServer` flattens
anything a tool callback throws into an isError result, dropping the code —
so the missing-capability refusal carries -32021 in structuredContent.
Also adds examples/mcp-tasks-demo: a Next.js app whose page is the MCP client,
showing the task lifecycle and the raw JSON-RPC wire log.
Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU
Verifying the QStash signature, reading the task id, counting which attempt this is, and picking the status code that decides whether QStash retries are all facts about the transport — not about the application. So the transport supplies the endpoint: `TaskDispatcher` gains an optional `createExecuteHandler(run)`, surfaced as `tasks.createExecuteHandler()`, and the demo's route collapses from ~40 lines to one. Skipping the signature check would let anyone who can reach the route run tasks; now it cannot be skipped. The shape is borrowed from Vercel Workflow's `Queue.createQueueHandler`, whose `World = Storage + Queue + Streamer` is the same split as our `TaskStore + TaskDispatcher`, and whose Upstash world makes the same two product choices. Status codes are the retry contract: 200 acks, 500 asks for a redelivery, and 401 (bad signature) / 400 (no task id) are deliberately terminal, because a retry cannot fix either and a 500 there would make QStash replay an unauthenticated request. Verification uses the published URL rather than `request.url`, since behind a proxy the incoming URL is the internal one while QStash signed the public destination. Retry defaults are re-tuned around a constraint found by testing: QStash caps `retries` per plan, and the local dev server and free tier reject anything above 5 with `quota maxRetries exceeded` (surfaced as an isError tool result, not a throw). The budget is therefore bought with backoff instead of attempts — `min(pow(3, retried) * 1000, 300000)` spreads five attempts over ~2 minutes rather than ~10 seconds. A budget shorter than a restart is exactly how a task gets dead-lettered while still reading `working`. Verified against live QStash: a task whose first delivery throws returns 500, is redelivered, and completes on the second attempt. Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU
The ecosystem note conflated two axes, and they invert. C# ships an IMcpTaskStore you can implement but only an in-memory implementation, so Redis is homework. FastMCP has no implementable seam — you pick memory:// or redis:// by URL scheme — yet Redis works out of the box, and it is still the only tasks implementation anywhere that makes the work durable rather than just the record. Claude-Session: https://claude.ai/code/session_01YGNUfzDFbQoteRB65VwMJU
…ransport A QStash delivery is one serverless invocation. It makes the work survive a crash, but not exceed a time limit: pass the platform's function limit and the invocation is killed, and because nothing recorded how far the handler got, the redelivery restarts it from step one. For a task measured in minutes or hours that is a livelock, not durability. `@upstash/mcp-tasks/workflow` adds `WorkflowDispatcher`, which runs each task as an Upstash Workflow run — one invocation per step, finished steps replayed from a journal. `TaskContext` gains `run(stepName, fn)` and `sleep(stepName, secs)`, which become durable checkpoints under Workflow and plain calls under a queue, so one handler runs under either dispatcher and only its durability changes. Verified end to end: the demo's handler was re-entered 19 times across invocations while each step body executed exactly once. That replay behaviour has a sharp edge worth knowing, now documented and applied in the demo: code *outside* a step re-runs on every invocation, so side effects (including status updates) belong inside `task.run`, while reads like `isCancelled()` belong outside. Retry bookkeeping also leaves the core, where it never belonged. Only the transport knows whether it will deliver again — QStash counts deliveries, Workflow retries per step, the inline dispatcher has no retries at all. So `executeTask` no longer takes `isFinalAttempt` and never settles a task `failed`; it rethrows and leaves the task `working`, and the dispatcher calls the new `failTask` when it has actually given up. QStash learns that from its failure callback, which fires only once every retry is exhausted and now lands on the *same* execute endpoint — one route, one signature check, the two shapes told apart by `sourceBody`. Workflow learns it from `failureFunction`. The callback also carries the DLQ id and the failed response, so a failed task now says something useful instead of just repeating the exception. Dispatchers receive the layer's entry points through a new `attach` hook, which removes the late-binding dance callers previously needed. Removed: ExecuteTaskOptions, TaskRunner, isFinalQStashAttempt, QSTASH_RETRIED_HEADER, QStashDispatcher.retries. Both drivers pass the same end-to-end suite against live Redis and QStash with identical handler code; the demo switches between them with TASKS_DRIVER. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
… in two Backends move into `src/backends/` (redis+qstash, workflow, memory), and `/upstash` becomes the single Upstash entry point — the standalone `/workflow` re-export is gone. The bigger change is that transports are no longer pretended to be interchangeable. `TaskDispatcher<TContext>` declares what it gives a running handler, and that flows through `createTaskLayer` into `registerTask`: a queue-backed layer hands the handler a `TaskContext`, while `createTaskLayer<WorkflowContext>` hands it `TaskContext & WorkflowContext` — one object with both `update`/`isCancelled` and the engine's real `run`, `sleep`, `call`, `waitForEvent`. That replaces the previous `TaskSteps` shim, which offered two methods that quietly did nothing useful on a queue. The compiler now rejects a workflow handler wired to a transport that cannot run it. The context is merged onto the engine's object rather than spread into a new one, because a WorkflowContext keeps its methods on the prototype; a test pins that, since a spread would compile fine and fail only against a real workflow. Two things the live runs surfaced, both fixed at the root: - `TaskStore.update` now no-ops on a terminal task, on both backends. The spec's "state does not change" covers the status message, and a write landing after a cancel was replacing "Cancelled by client" with an error string. The Redis path does it with the same Lua guard `settle` already used, which also makes update cheaper (one round trip instead of three). - `executeTask` no longer records anything when the handler throws. The core cannot tell a real failure from a workflow engine suspending the handler mid-step, and it was writing "attempt failed" over healthy runs. The SDK also journals its own writes now: under a workflow, `task.update(...)` runs once instead of on every replay, and users do not wrap it themselves. Journaling is skipped when already inside a step, since the engine rejects nested steps. `isCancelled` is deliberately not journaled — it must read live state, or a cancel arriving later would never be seen. The demo is now two servers rather than one env switch, because the handlers genuinely differ: `/api/mcp` + `/api/execute` on QStash, `/api/mcp-workflow` + `/api/execute-workflow` on Workflow, with a driver picker in the UI. Both pass the same end-to-end suite against live Redis and QStash. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…bility The usage section is now the smallest thing that conveys the idea — no optional parameters in the main snippet — and everything else moves into toggles: the Store and Dispatcher interfaces, the options tables, sequence diagrams for tools/call, execution and tasks/get + tasks/cancel showing which layer owns what, and an FAQ. The FAQ answers the questions this package actually raises: what the execute endpoint does on your behalf, why it serves through the transport rather than the SDK's createMcpHandler, why a missing capability arrives as a structured tool error instead of -32021, how long retries last and what happens when they run out, and whether a task id is a secret. Adds an mcp-handler section. It wraps the SDK's own createMcpHandler, so `tasks/get` and `tasks/cancel` come back -32601 before the handler is looked up — but task *creation* works untouched, and renaming the two methods via the `methods` option makes the rest dispatch. Verified by running the README's snippet against the real packages: tools/call returns a handle and `upstash/tasks.get` polls it to completed. Also drops the two Vercel Workflow comparisons from the retry docs. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
Progress reporting and cancellation are opt-in, so they move out of the opening example into a toggle. What is left is the minimum a working server needs. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
The endpoint belongs to the dispatcher, so what it does differs by dispatcher — but the FAQ answered only for QStash while claiming to describe it generally. Signatures, failure callbacks and retry status codes are QStash's; Workflow serves the engine's own handler and owns authentication and replay itself; an in-process dispatcher has no endpoint at all. The execution flow diagram said 'verify signature' for the same reason and now says the transport authenticates the delivery. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…e intro The README explained what the package does but not the thing an SDK maintainer would actually want to know: what of this belongs upstream. It separates the two levels. Almost all of it can live outside the SDK — this package is additive over @modelcontextprotocol/server, which is itself the finding. Two things cannot: tasks/get and tasks/cancel are undispatchable on the 2026-07-28 era (in the 2025 registry, dropped from 2026, so the gate answers -32601 before handler lookup), and a tool callback cannot return a JSON-RPC error, which makes the spec's -32021 for a missing tasks capability unreachable. Both workarounds for the first are spelled out along with what each costs. Then the design point, if a runtime does ship: two interfaces rather than one. The store half already has precedent in C#'s IMcpTaskStore; the dispatcher half exists in no official SDK, which is why every one of them ends up with a durable record and non-durable work — fine on a long-lived host, fatal on serverless. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…ning the callback endpoint 'The SDK' was ambiguous throughout a section aimed at SDK maintainers, and in one place it read as though @modelcontextprotocol/server journalled our writes when the journalling is ours. Everything is now named, and the section states which version the findings were verified against. The callback endpoint is promoted from an aside to its own point. Once the work runs outside the request something has to call back in, so a task server needs a second route the spec never describes — and every serverless implementation reinvents it along with the delicate parts: authenticating the caller, telling a delivery from a failure notification, and picking the status code that decides whether the transport retries. That is transport knowledge, not application knowledge, so the runtime should hand back a finished endpoint. It also notes this leaves single-endpoint servers possible, since the transport authenticates its own deliveries. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
…sise the shape The endpoint had ended up inside the speculative 'if a runtime ships' part, where it read as design preference. It belongs with the other findings: every task server needs a second route the spec never describes, and each one re-implements authenticating the caller, telling a delivery from a failure notification, and picking the retry status code. 'Two things only @modelcontextprotocol/server can fix' no longer fits, since this package does implement the third — so the heading is now 'Three gaps', with a line separating the two nobody can work around from the one everybody re-solves, where a mistake is a security bug rather than a missing feature. The remaining design suggestion is collapsed into a toggle and trimmed to the two interfaces, so the section leads with what was observed rather than what we would prefer. Claude-Session: https://claude.ai/code/session_01KGQgqY83KYuYLFh6Ef3Bau
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Introduce a new package,
@upstash/mcp-tasks, that implements a durable runtime for MCP Tasks, enabling long-running tools to operate with stateful behavior. This package provides acreateTaskLayerfunction to manage task execution and state across different backends, including Upstash Redis and QStash. The implementation ensures that tasks can survive process restarts and provides a robust retry mechanism. Additionally, it includes a demo application to showcase the task lifecycle and interactions with the MCP protocol. Documentation clarifies the separation of transport responsibilities and the behavior of the new dispatchers.