Skip to content

Add @upstash/mcp-tasks: a durable MCP Tasks runtime - #38

Open
CahidArda wants to merge 11 commits into
mainfrom
worktree-mcp-tasks
Open

Add @upstash/mcp-tasks: a durable MCP Tasks runtime#38
CahidArda wants to merge 11 commits into
mainfrom
worktree-mcp-tasks

Conversation

@CahidArda

Copy link
Copy Markdown
Collaborator

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 a createTaskLayer function 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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant