Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/hot-jars-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@upstash/mcp-tasks": minor
---

Let the dispatcher own its delivery endpoint, and give the retry budget a chance to outlast a
restart.

`TaskDispatcher` gains an optional `createExecuteHandler(run)`, surfaced as
`tasks.createExecuteHandler()`. 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, so the transport now supplies the endpoint: an app route is
`export const POST = tasks.createExecuteHandler()` instead of a hand-written handler that has to
remember `Receiver.verify`.

Retry defaults are re-tuned around a constraint worth knowing: QStash caps `retries` per plan, and
the local dev server and free tier reject anything above 5. So the budget is bought with backoff
rather than attempts — `DEFAULT_RETRY_DELAY` is now `min(pow(3, retried) * 1000, 300000)`, spreading
five attempts over roughly two minutes instead of ten seconds. A budget shorter than a restart is
how a task ends up dead-lettered while still reading `working`.
33 changes: 33 additions & 0 deletions .changeset/olive-pans-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@upstash/mcp-tasks": minor
---

Add a Workflow dispatcher, and let each transport decide when a failure is final.

`@upstash/mcp-tasks/upstash` now also exports `WorkflowDispatcher`, which runs each task as an
Upstash Workflow run — one invocation per step, with finished steps replayed from a journal. That
is the difference between surviving a crash and outliving a time limit: a QStash delivery is a
single serverless invocation, so exceeding the platform's function limit kills the work and the
redelivery restarts the handler from the beginning.

The layer is now generic over what its transport provides. `createTaskLayer<WorkflowContext>(...)`
gives handlers `TaskContext & WorkflowContext` — one object carrying both `update`/`isCancelled`
and the engine's real `run`, `sleep`, `call`, `waitForEvent` — while a queue-backed layer gives
just the `TaskContext`. Transports are not interchangeable, and the types now say so instead of
papering over it with a lowest-common-denominator shim. The SDK journals its own writes, so
`task.update(...)` is not repeated when a workflow replays the handler.

`TaskStore.update` is now ignored once a task is terminal, on both backends. The spec's "state does
not change" covers the status message, and a progress write landing after a cancel was overwriting
"Cancelled by client".

Retry bookkeeping moves out of the core. `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` once it has genuinely stopped retrying. QStash learns that from its own failure callback,
which fires only after every retry is exhausted and now arrives at the *same* execute endpoint —
one route, one signature check, told apart by the body. Workflow learns it from `failureFunction`.
Nothing in the package counts attempts or reads a retry header any more.

Removed: `ExecuteTaskOptions`, `TaskRunner`, `isFinalQStashAttempt`, `QSTASH_RETRIED_HEADER`, and
the public `QStashDispatcher.retries` field. Added: `TaskEndpoints`, `TaskSteps`,
`TaskDispatcher.attach`, and `TaskLayer.failTask`; `createExecuteHandler` now takes no arguments.
13 changes: 13 additions & 0 deletions .changeset/spotty-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@upstash/mcp-tasks": minor
---

Add `@upstash/mcp-tasks`: a durable MCP Tasks runtime for the official TypeScript SDK.

The 2026-07-28 spec made MCP stateless and moved long-running tools to the Tasks extension, but the
official v2 SDK ships the wire schemas with no runtime behind them. This package adds one:
`createTaskLayer({ store, dispatcher })` turns a tool into a task-returning tool and serves
`tasks/get` and `tasks/cancel`, over two swappable interfaces — a `TaskStore` for the record and a
`TaskDispatcher` for the execution. `@upstash/mcp-tasks/upstash` provides both on Upstash Redis
(one hash per task, `PEXPIRE` for TTL) and QStash (durable at-least-once delivery to your execute
endpoint), so work survives the process that accepted the tool call.
82 changes: 81 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ embeddings — keep that in mind when naming/among scoring.
| `@upstash/agentkit-eve` (`packages/eve`) | Eve framework adapter. Depends on the ai-sdk package. |
| `@upstash/agentkit-eve-extension` (`packages/eve-extension`) | AgentKit as a mountable **eve extension** (eve ≥0.24): one `agent/extensions/<ns>.ts` file composes memory tools, search tools, a chat-history hook, and an instructions fragment under `<ns>__*`. |

| `@upstash/mcp-tasks` (`packages/mcp-tasks`) | A durable **MCP Tasks** runtime for the official `@modelcontextprotocol/server` v2. **Not an `agentkit-*` package** — separate name, versioned independently (the changesets `linked` glob only covers `@upstash/agentkit-*`), and it depends on none of the others. |

Examples (`examples/`): `ai-sdk-demo` (hand-written Next.js), `eve-demo` (a real `eve` CLI scaffold),
and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
`eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-tasks-demo`
(Next.js; the MCP server plus a browser client that shows the JSON-RPC wire log).
`langchain` and `tanstack-ai` packages were **removed** — don't reintroduce them.

### Core SDK exports (`@upstash/agentkit-sdk`)
Expand Down Expand Up @@ -309,6 +312,83 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
`$and/$or/$must/$should/$mustNot`. Aggregations: `$terms`, `$stats`, `$sum`, `$avg`, `$min`, `$max`,
`$count`, `$histogram`, `$percentiles`, `$cardinality`.

## MCP Tasks facts (`packages/mcp-tasks`) — IMPORTANT
Verified empirically against `@modelcontextprotocol/server@2.0.0`; don't re-derive them from the docs.
- **The SDK has schemas but no tasks runtime.** v2 ships `Task`/`GetTaskRequest`/`CreateTaskResult`
etc. and `isTaskAugmentedRequestParams`, but registers **no** `tasks/*` handler and has no store.
v1's experimental task APIs were removed with no migration path. That gap is what this package fills.
- **`createMcpHandler` cannot serve `tasks/get`/`tasks/cancel`.** It pins the request to the
2026-07-28 era from the client's `_meta` protocol-version claim, and that era's dispatch gate
returns **`-32601` before the handler is looked up**: those strings are in the SDK's *2025* method
registry (so `isSpecRequestMethod` is true) and absent from the *2026* one. A `fallbackRequestHandler`
does not help — the gate returns first. Proven: the registered handler never runs, while a
namespaced `upstash/tasks.get` on the same server dispatches fine.
**So the demo and the docs use `WebStandardStreamableHTTPServerTransport` + `transport.handleRequest`**,
which leaves the instance on the 2025 era where `tasks/*` dispatch normally. `createTaskLayer`'s
`methods` option is the escape hatch for `createMcpHandler` users.
- **`supportedProtocolVersions: [TASKS_PROTOCOL_VERSION]` on the `McpServer` is required**, or the
transport rejects every 2026-07-28 request with "Unsupported protocol version" (its default list is
the 2025 era's). There is no *public* 2026 constant in the SDK — `SUPPORTED_PROTOCOL_VERSIONS` is
legacy-only and `LATEST_PROTOCOL_VERSION` is `"2025-11-25"`.
- **The per-request envelope works on both eras:** `ctx.mcpReq.envelope[CLIENT_CAPABILITIES_META_KEY]`
carries the lifted client capabilities. That is the capability check — there is no session to ask.
- **A tool callback cannot return a JSON-RPC error.** `McpServer` catches everything a tool callback
throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and flattens it to
`{content, isError:true}`, **dropping the code**. So the missing-capability refusal is a structured
tool error with `structuredContent: { code: -32021, requiredCapabilities }`, not a thrown error.
- **`resultType: "task"` from `tools/call` is allowed** (`tools/call` is in the SDK's
`EXTENDED_RESULT_TYPE_METHODS`, forwarded verbatim). We return the task **flattened**, not under a
`task` key: `"task"` is a hard-coded "foreign family" key that blocks the SDK's contentless-result
default, so `{resultType:"task", task:{…}}` without `content` is rejected — the flattened form gets
`content: []` filled in automatically.
- **Design choices that differ from the naive version** (all covered by tests):
`TaskStore.settle` is a *guarded, atomic* terminal transition (a Lua script on Redis) so a client's
`tasks/cancel` and the executor completing cannot clobber each other — first terminal write wins;
the store keeps **one hash field per task property** (not one JSON blob) so a progress `update` and
a cancel never overwrite each other's fields; and `executeTask(id, {isFinalAttempt})` keeps a task
**`working`** until the dispatcher's last delivery, because settling `failed` on the first error
makes it terminal and every retry then no-ops on the redelivery guard.
- **Redis encoding:** every hash field is written `JSON.stringify`d and read back with **no decode of
our own** — `@upstash/redis` auto-`JSON.parse`s responses, so the single parse is the exact inverse.
Decoding again turns a `statusMessage` of `"123"` into the number `123` (this actually happened).
- **QStash retry budget must outlast a restart.** `Upstash-Retried` (count so far, from 0) is the only
retry header; there is no max-retries header, so `isFinalQStashAttempt(headers, dispatcher.retries)`
takes the configured max. With a flat `"1000"` delay a kill-9'd server exhausts all retries in ~10s
and the task is dead-lettered while still reading `working` — observed, then fixed, then re-verified
end to end (kill -9 mid-task → restart → QStash redelivery → `completed`).
**`retries` is plan-capped:** the local dev server and the free tier reject anything above **5**
with `quota maxRetries exceeded` (this bit a `DEFAULT_RETRIES = 12` attempt — the tool call comes
back as an `isError` result carrying that message, not as a thrown error). So the budget is bought
with backoff instead: `DEFAULT_RETRIES = 5` and
`DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"` ≈ 2 minutes over five attempts.
- **The dispatcher owns its delivery endpoint** (`TaskDispatcher.createExecuteHandler?(run)`, surfaced
as `tasks.createExecuteHandler()`): signature verification, task-id parsing, attempt counting and
the retry status codes live in the transport, so an app route is one line and cannot forget
`Receiver.verify`. Modelled on Vercel Workflow's `Queue.createQueueHandler` (see below). Status
contract: **200** ack, **401** bad signature, **400** no task id (both terminal — a retry cannot fix
either), **500** only when the task threw and QStash still has attempts. Verification uses the
**published** `url`, not `request.url`, because behind a proxy the incoming URL is the internal one
while QStash signed the public destination.
- **Ecosystem context (verified 2026-09).** Keep two axes apart when reading this — *is there an
interface you can implement* is not *does Redis work today*, and the answers invert.
Among *official* MCP SDKs, only **C#** ships a store interface you can implement (`IMcpTaskStore`,
7 methods) — but the only in-box implementation is `InMemoryMcpTaskStore`, so Redis is homework.
Rust's `TaskManager` is a concrete in-memory struct with no trait; Python/Java have store
interfaces only in unmerged PRs; Go/Kotlin/Swift/Ruby have none.
Unofficial **FastMCP** is the mirror image: no implementable seam (Docket is both queue and store,
and you pick a backend by URL scheme — `memory://` or `redis://`, nothing else), but Redis works
out of the box with one URL, and it is the only tasks implementation anywhere that makes the
*work* durable (Docket queue plus `worker_cli` workers out of process; the memory backend is
single-process).
**No official SDK in any language abstracts execution** — all of them `Task.Run`/`tokio::spawn`/
`.subscribe()` in-process, i.e. durable record, non-durable work. So this package's
`TaskStore` + `TaskDispatcher` split is not a port of prior MCP art — the closest analogue is
Vercel Workflow's `World = Storage + Queue + Streamer`.
- Tests: `src/core.test.ts` drives a real `McpServer` + real transport over genuine JSON-RPC;
`src/upstash.test.ts` hits real Redis. Both run under the root vitest config.
- **Local dev needs the QStash dev server** (`npx @upstash/qstash-cli dev`) — it prints deterministic
creds. `APP_URL` must be reachable *from QStash*.

## Eve framework facts
- The repo is on **`eve@0.47.3`** everywhere (`packages/eve`, `packages/eve-extension`, `examples/eve-demo`,
`examples/eve-extension-demo`). `packages/eve`'s peer stays
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr
| [`@upstash/agentkit-ai-sdk`](./packages/ai-sdk) | Adapter for the [Vercel AI SDK](https://ai-sdk.dev). |
| [`@upstash/agentkit-eve`](./packages/eve) | Adapter for the Vercel Eve framework. |
| [`@upstash/agentkit-eve-extension`](./packages/eve-extension) | The same capabilities as a mountable [Eve extension](https://eve.dev/docs/extensions) — one file in `agent/extensions/` adds memory tools, search tools, and durable chat history the agent can search. |
| [`@upstash/mcp-tasks`](./packages/mcp-tasks) | A durable [MCP Tasks](https://github.com/modelcontextprotocol/ext-tasks) runtime for the official TypeScript SDK: long-running tools answer with a task handle, the record lives in Redis, and the work runs through QStash. |

## Core features

Expand All @@ -28,13 +29,17 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr
- **Code sandbox** (Eve only) — a drop-in [Upstash Box](https://github.com/upstash/box) backend for
Eve's `defineSandbox`.
- **Tool-call cache** — memoize deterministic tool results keyed by arguments.
- **Durable MCP tasks** (`@upstash/mcp-tasks`) — a long-running MCP tool returns a task handle
instead of blocking; the task record lives in Redis and the work runs through QStash, so it
survives the process that accepted the call.

## Examples

Runnable demos (real Upstash Redis + a mock/real model) live in [`examples/`](./examples):
[`ai-sdk-demo`](./examples/ai-sdk-demo), [`eve-demo`](./examples/eve-demo), and
[`ai-sdk-demo`](./examples/ai-sdk-demo), [`eve-demo`](./examples/eve-demo),
[`eve-extension-demo`](./examples/eve-extension-demo) (an eve agent that mounts
`@upstash/agentkit-eve-extension`).
`@upstash/agentkit-eve-extension`), and [`mcp-tasks-demo`](./examples/mcp-tasks-demo) (an MCP server
whose long-running tool returns a task handle, with the client's wire log on screen).

## Development

Expand Down
22 changes: 22 additions & 0 deletions examples/mcp-tasks-demo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Upstash Redis — the durable task record.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# QStash — the durable execution transport.
#
# For local development run `npx @upstash/qstash-cli dev` in another terminal. It prints a URL, a
# token and both signing keys; paste them here. For a deployed app use the real values from the
# Upstash console instead and drop QSTASH_URL.
QSTASH_URL=http://127.0.0.1:8080
QSTASH_TOKEN=
QSTASH_CURRENT_SIGNING_KEY=
QSTASH_NEXT_SIGNING_KEY=

# Where QStash delivers a task. Must be reachable *from QStash*: the local dev server can reach
# 127.0.0.1, the hosted service cannot — use your deployment URL or a tunnel there.
APP_URL=http://127.0.0.1:3000

# Which transport runs the work. Both serve the same /api/execute route.
# qstash (default) one delivery, one invocation — bounded by the function limit
# workflow one invocation per step — can outlive the function limit
TASKS_DRIVER=qstash
4 changes: 4 additions & 0 deletions examples/mcp-tasks-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
.next
.env*
!.env.example
Loading