From a9eaaf1fba8e8c76e070a1cadf7dfa3ea7aa774f Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 1 Sep 2026 13:43:20 +0300 Subject: [PATCH 01/11] feat: add @upstash/mcp-tasks, a durable MCP Tasks runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/spotty-donkeys-shave.md | 13 + CLAUDE.md | 55 ++- README.md | 9 +- examples/mcp-tasks-demo/.env.example | 17 + examples/mcp-tasks-demo/.gitignore | 4 + examples/mcp-tasks-demo/README.md | 84 ++++ .../mcp-tasks-demo/app/api/execute/route.ts | 53 +++ examples/mcp-tasks-demo/app/api/mcp/route.ts | 35 ++ examples/mcp-tasks-demo/app/globals.css | 386 ++++++++++++++++ examples/mcp-tasks-demo/app/layout.tsx | 18 + examples/mcp-tasks-demo/app/lib/mcp-client.ts | 146 +++++++ examples/mcp-tasks-demo/app/lib/tasks.ts | 77 ++++ examples/mcp-tasks-demo/app/page.tsx | 288 ++++++++++++ examples/mcp-tasks-demo/next-env.d.ts | 6 + examples/mcp-tasks-demo/next.config.ts | 5 + examples/mcp-tasks-demo/package.json | 28 ++ examples/mcp-tasks-demo/scripts/smoke.mjs | 99 +++++ examples/mcp-tasks-demo/tsconfig.json | 41 ++ packages/mcp-tasks/README.md | 222 ++++++++++ packages/mcp-tasks/package.json | 74 ++++ packages/mcp-tasks/src/core.test.ts | 412 ++++++++++++++++++ packages/mcp-tasks/src/core.ts | 411 +++++++++++++++++ packages/mcp-tasks/src/index.ts | 40 ++ packages/mcp-tasks/src/memory.ts | 105 +++++ packages/mcp-tasks/src/telemetry.ts | 54 +++ packages/mcp-tasks/src/test-support.ts | 41 ++ packages/mcp-tasks/src/types.ts | 159 +++++++ packages/mcp-tasks/src/upstash.test.ts | 184 ++++++++ packages/mcp-tasks/src/upstash.ts | 325 ++++++++++++++ packages/mcp-tasks/src/version.ts | 2 + packages/mcp-tasks/tsconfig.json | 8 + packages/mcp-tasks/tsup.config.ts | 13 + pnpm-lock.yaml | 127 ++++++ scripts/sync-version.mjs | 1 + 34 files changed, 3539 insertions(+), 3 deletions(-) create mode 100644 .changeset/spotty-donkeys-shave.md create mode 100644 examples/mcp-tasks-demo/.env.example create mode 100644 examples/mcp-tasks-demo/.gitignore create mode 100644 examples/mcp-tasks-demo/README.md create mode 100644 examples/mcp-tasks-demo/app/api/execute/route.ts create mode 100644 examples/mcp-tasks-demo/app/api/mcp/route.ts create mode 100644 examples/mcp-tasks-demo/app/globals.css create mode 100644 examples/mcp-tasks-demo/app/layout.tsx create mode 100644 examples/mcp-tasks-demo/app/lib/mcp-client.ts create mode 100644 examples/mcp-tasks-demo/app/lib/tasks.ts create mode 100644 examples/mcp-tasks-demo/app/page.tsx create mode 100644 examples/mcp-tasks-demo/next-env.d.ts create mode 100644 examples/mcp-tasks-demo/next.config.ts create mode 100644 examples/mcp-tasks-demo/package.json create mode 100644 examples/mcp-tasks-demo/scripts/smoke.mjs create mode 100644 examples/mcp-tasks-demo/tsconfig.json create mode 100644 packages/mcp-tasks/README.md create mode 100644 packages/mcp-tasks/package.json create mode 100644 packages/mcp-tasks/src/core.test.ts create mode 100644 packages/mcp-tasks/src/core.ts create mode 100644 packages/mcp-tasks/src/index.ts create mode 100644 packages/mcp-tasks/src/memory.ts create mode 100644 packages/mcp-tasks/src/telemetry.ts create mode 100644 packages/mcp-tasks/src/test-support.ts create mode 100644 packages/mcp-tasks/src/types.ts create mode 100644 packages/mcp-tasks/src/upstash.test.ts create mode 100644 packages/mcp-tasks/src/upstash.ts create mode 100644 packages/mcp-tasks/src/version.ts create mode 100644 packages/mcp-tasks/tsconfig.json create mode 100644 packages/mcp-tasks/tsup.config.ts diff --git a/.changeset/spotty-donkeys-shave.md b/.changeset/spotty-donkeys-shave.md new file mode 100644 index 0000000..a376e4a --- /dev/null +++ b/.changeset/spotty-donkeys-shave.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index c0b7828..2cf65d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.ts` file composes memory tools, search tools, a chat-history hook, and an instructions fragment under `__*`. | +| `@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`) @@ -309,6 +312,56 @@ 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. The default `retryDelay` is **exponential** (`"pow(2, retried) * 1000"`): + with a flat `"1000"` 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`). +- 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 diff --git a/README.md b/README.md index ccc0ebf..27eff64 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/examples/mcp-tasks-demo/.env.example b/examples/mcp-tasks-demo/.env.example new file mode 100644 index 0000000..65023f2 --- /dev/null +++ b/examples/mcp-tasks-demo/.env.example @@ -0,0 +1,17 @@ +# 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 diff --git a/examples/mcp-tasks-demo/.gitignore b/examples/mcp-tasks-demo/.gitignore new file mode 100644 index 0000000..3556585 --- /dev/null +++ b/examples/mcp-tasks-demo/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +.env* +!.env.example diff --git a/examples/mcp-tasks-demo/README.md b/examples/mcp-tasks-demo/README.md new file mode 100644 index 0000000..2d3e30a --- /dev/null +++ b/examples/mcp-tasks-demo/README.md @@ -0,0 +1,84 @@ +# MCP Tasks demo + +A Next.js app showing `@upstash/mcp-tasks` end to end: an MCP tool that answers with a task handle +instead of blocking, a task record in Upstash Redis, and the work running through QStash so it +survives the process that accepted the call. + +The page is the MCP client. It speaks raw stateless JSON-RPC to `/api/mcp` — no initialize +handshake, no session id — and shows every frame it sends and receives in a wire log next to the +tasks, so you can watch the protocol rather than just the result. + +## What's here + +| File | What it does | +| --- | --- | +| `app/lib/tasks.ts` | The whole server wiring: `RedisTaskStore`, `QStashDispatcher`, and the `generate_report` task tool | +| `app/api/mcp/route.ts` | The MCP endpoint, over `WebStandardStreamableHTTPServerTransport` | +| `app/api/execute/route.ts` | Where QStash delivers a task — verifies the signature, then runs it | +| `app/page.tsx` | The client: call the tool, poll, cancel, and the wire log | +| `scripts/smoke.mjs` | Drives the same flow from the terminal and asserts on it | + +## Run it + +You need an [Upstash Redis database](https://upstash.com/start-redis). QStash you can run locally, +fully offline. + +```bash +cp .env.example .env.local # fill in UPSTASH_REDIS_REST_URL / _TOKEN + +pnpm qstash # terminal 1 — prints the QStash URL, token and signing keys + # paste those four into .env.local +pnpm dev # terminal 2 +``` + +Then open http://localhost:3000, type a topic, and hit **Run tool**. + +`APP_URL` is the one setting worth reading twice: it is where QStash delivers the task, so it has to +be reachable *from QStash*. The local dev server can reach `127.0.0.1`; the hosted service cannot, +so a deployed app needs its real URL (or a tunnel) there. + +To check everything from the terminal instead: + +```bash +pnpm smoke # happy path, cancel mid-flight, a client without the capability, unknown task id +``` + +## The three things worth watching + +**A tool call returns immediately.** `tools/call` comes back in milliseconds with +`resultType: "task"` and a `working` status. The four-step report takes about ten seconds; none of +it happens inside that request. + +**Cancel is cooperative, in three layers.** Hit **cancel** mid-run and the store flips the status +to `cancelled`, the dispatcher cancels the pending QStash message, and the handler stops at its +next step boundary. The last layer is the one you cannot skip: running code only stops where it +checks. A completion arriving after the cancel is refused — terminal states are final. + +**The work is durable, not just the record.** Start a task and kill the dev server mid-run: + +```bash +pnpm dev +# start a task in the browser, then, a few seconds in: +kill -9 $(lsof -ti tcp:3000) +pnpm dev +``` + +Keep polling (the page resumes on its own) and the task still reaches `completed`. Redis kept the +record; QStash's redelivery is what finished the work. Replace the dispatcher with a +fire-and-forget promise and the same test leaves a permanently `working` task instead. + +One caveat this demo learned the hard way: the retry budget has to outlast your restart. QStash +retries on its configured schedule and dead-letters the message when they run out, so with a flat +one-second delay every attempt is spent within a few seconds — long before a dev server is back up, +leaving a task that reads `working` forever. The dispatcher defaults to exponential backoff +(1s, 2s, 4s, 8s, 16s) for that reason. If a task ends up dead-lettered anyway, it is in the QStash +DLQ, not lost. + +## Notes + +- The tool is an ordinary MCP tool. Nothing in `tools/list` marks it as a task; the server decides + per call, from the capabilities the request carries. +- A client that has not declared `io.modelcontextprotocol/tasks` gets a structured tool error + telling it what to declare, instead of a task it cannot poll. +- The route uses `WebStandardStreamableHTTPServerTransport` rather than `createMcpHandler` on + purpose — see the note in `app/api/mcp/route.ts` and the package README. diff --git a/examples/mcp-tasks-demo/app/api/execute/route.ts b/examples/mcp-tasks-demo/app/api/execute/route.ts new file mode 100644 index 0000000..38d7da1 --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/execute/route.ts @@ -0,0 +1,53 @@ +/** + * The endpoint QStash delivers a task to. + * + * This is where the work actually runs — in a different request, and possibly a different process, + * from the `tools/call` that created the task. That separation is the whole point: the process + * that accepted the call can die without taking the work with it. + */ +import { Receiver } from "@upstash/qstash"; +import { isFinalQStashAttempt } from "@upstash/mcp-tasks/upstash"; +import { dispatcher, EXECUTE_URL, tasks } from "../../lib/tasks"; + +export const dynamic = "force-dynamic"; +// The demo tool sleeps for ~10s; give the platform room to let it finish. +export const maxDuration = 60; + +const receiver = new Receiver({ + currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY ?? "", + nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY ?? "", +}); + +export async function POST(request: Request): Promise { + const body = await request.text(); + + // Without this, anyone who can reach the route can run tasks. + try { + await receiver.verify({ + signature: request.headers.get("upstash-signature") ?? "", + body, + url: EXECUTE_URL, + }); + } catch (cause) { + console.error("[execute] signature verification failed", cause); + // 401 is deliberate: a bad signature is not something a retry can fix. + return new Response("invalid signature", { status: 401 }); + } + + const { taskId } = JSON.parse(body) as { taskId?: string }; + if (!taskId) return new Response("missing taskId", { status: 400 }); + + // Whether a thrown handler is fatal depends on whether QStash will try again. + const isFinalAttempt = isFinalQStashAttempt(request.headers, dispatcher.retries); + + try { + console.log(`[execute] task=${taskId} starting (final attempt: ${isFinalAttempt})`); + const task = await tasks.executeTask(taskId, { isFinalAttempt }); + console.log(`[execute] task=${taskId} -> ${task?.status ?? "gone"}`); + return new Response("ok"); + } catch (cause) { + console.error(`[execute] task=${taskId} failed`, cause); + // A non-2xx is how you ask QStash to retry. + return new Response("retry", { status: 500 }); + } +} diff --git a/examples/mcp-tasks-demo/app/api/mcp/route.ts b/examples/mcp-tasks-demo/app/api/mcp/route.ts new file mode 100644 index 0000000..34abb1a --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/mcp/route.ts @@ -0,0 +1,35 @@ +/** + * The MCP endpoint. + * + * Note this uses `WebStandardStreamableHTTPServerTransport` rather than `createMcpHandler`. Both + * take a web `Request` and return a `Response`, but `createMcpHandler` pins the request to the + * 2026-07-28 era, and on that era the SDK's dispatch gate answers `tasks/get` and `tasks/cancel` + * with `-32601` before your handler is ever looked up. See `TASK_METHODS` in `@upstash/mcp-tasks` + * for the details and the namespaced-method workaround. + */ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/tasks"; + +// Every request builds its own server and transport: the protocol is stateless now, so there is +// nothing to keep between requests, and any instance can serve any request. +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + const server = createServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + // No SSE stream here, so a keepalive would only leak a timer per request. + keepAliveMs: 0, + }); + await server.connect(transport); + + try { + const response = await transport.handleRequest(request); + // Buffer the body before closing, so the response cannot be cut off by the close below. + const body = await response.text(); + return new Response(body, { status: response.status, headers: response.headers }); + } finally { + await transport.close(); + } +} diff --git a/examples/mcp-tasks-demo/app/globals.css b/examples/mcp-tasks-demo/app/globals.css new file mode 100644 index 0000000..980e43e --- /dev/null +++ b/examples/mcp-tasks-demo/app/globals.css @@ -0,0 +1,386 @@ +:root { + --bg: #0b0f0e; + --panel: #111716; + --panel-2: #161d1c; + --line: #223029; + --text: #e8f2ee; + --muted: #8ba39a; + --accent: #00e9a3; + --accent-dim: #0c6b52; + --amber: #ffc857; + --red: #ff6b6b; + --blue: #6bb8ff; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: + ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +a { + color: var(--accent); +} + +.shell { + max-width: 1180px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +header.masthead { + border-bottom: 1px solid var(--line); + padding-bottom: 20px; + margin-bottom: 24px; +} + +header.masthead h1 { + margin: 0 0 6px; + font-size: 22px; + letter-spacing: -0.01em; +} + +header.masthead p { + margin: 0; + color: var(--muted); + max-width: 70ch; +} + +.pill { + display: inline-block; + font-family: var(--mono); + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + margin-right: 6px; +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} + +.panel > h2 { + margin: 0; + padding: 12px 16px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + border-bottom: 1px solid var(--line); + background: var(--panel-2); + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.panel-body { + padding: 16px; +} + +form.launch { + display: flex; + gap: 8px; +} + +input[type="text"] { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--line); + color: var(--text); + border-radius: 8px; + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +input[type="text"]:focus { + outline: none; + border-color: var(--accent-dim); +} + +button { + background: var(--accent); + color: #04231b; + border: 0; + border-radius: 8px; + padding: 10px 16px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + font-family: inherit; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.ghost { + background: transparent; + border: 1px solid var(--line); + color: var(--muted); + padding: 5px 10px; + font-size: 12px; + font-weight: 500; +} + +button.ghost:hover:not(:disabled) { + color: var(--text); + border-color: var(--muted); +} + +.hint { + color: var(--muted); + font-size: 12px; + margin-top: 10px; +} + +.tasks { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.task { + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px; + background: var(--panel-2); +} + +.task-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.task-id { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); +} + +.task-topic { + font-weight: 600; +} + +.spacer { + flex: 1; +} + +.badge { + font-family: var(--mono); + font-size: 11px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid currentColor; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge.working { + color: var(--blue); +} + +.badge.input_required { + color: var(--amber); +} + +.badge.completed { + color: var(--accent); +} + +.badge.failed { + color: var(--red); +} + +.badge.cancelled { + color: var(--muted); +} + +.status-line { + margin-top: 10px; + color: var(--muted); + font-size: 13px; + min-height: 20px; +} + +.bar { + margin-top: 10px; + height: 4px; + background: #0a1210; + border-radius: 999px; + overflow: hidden; +} + +.bar > span { + display: block; + height: 100%; + background: var(--accent); + transition: width 0.4s ease; +} + +.bar.cancelled > span { + background: var(--muted); +} + +.bar.failed > span { + background: var(--red); +} + +.meta { + margin-top: 10px; + display: flex; + gap: 14px; + flex-wrap: wrap; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} + +.result { + margin-top: 12px; + border-top: 1px dashed var(--line); + padding-top: 12px; +} + +.result p { + margin: 0 0 8px; +} + +pre { + margin: 0; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + +.log { + max-height: 620px; + overflow-y: auto; + padding: 8px 0; +} + +.frame { + padding: 8px 16px; + border-bottom: 1px solid #1a2422; +} + +.frame:last-child { + border-bottom: 0; +} + +.frame-head { + display: flex; + gap: 8px; + align-items: baseline; + font-family: var(--mono); + font-size: 11px; +} + +.frame-head .dir { + color: var(--accent); + font-weight: 700; +} + +.frame-head .dir.in { + color: var(--blue); +} + +.frame-head .method { + color: var(--text); +} + +.frame-head .time { + color: #4d635b; + margin-left: auto; +} + +.frame pre { + margin-top: 5px; + color: var(--muted); + font-size: 11px; +} + +.empty { + color: var(--muted); + font-size: 13px; + padding: 8px 0; +} + +.callout { + margin-top: 20px; + border: 1px solid var(--line); + border-left: 3px solid var(--accent-dim); + border-radius: 8px; + padding: 14px 16px; + background: var(--panel); +} + +.callout h3 { + margin: 0 0 6px; + font-size: 13px; +} + +.callout p { + margin: 0 0 8px; + color: var(--muted); + font-size: 13px; +} + +.callout code, +.inline-code { + font-family: var(--mono); + font-size: 12px; + background: #0a1210; + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; +} + +.banner { + border: 1px solid #5a3a2a; + background: #2a1a12; + color: #ffd9a8; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 20px; + font-size: 13px; +} diff --git a/examples/mcp-tasks-demo/app/layout.tsx b/examples/mcp-tasks-demo/app/layout.tsx new file mode 100644 index 0000000..a4a6156 --- /dev/null +++ b/examples/mcp-tasks-demo/app/layout.tsx @@ -0,0 +1,18 @@ +import "./globals.css"; +import type { ReactNode } from "react"; + +export const metadata = { + title: "MCP Tasks on Upstash", + description: + "Durable long-running MCP tools: a task record in Upstash Redis, execution on QStash.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
{children}
+ + + ); +} diff --git a/examples/mcp-tasks-demo/app/lib/mcp-client.ts b/examples/mcp-tasks-demo/app/lib/mcp-client.ts new file mode 100644 index 0000000..a99523e --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/mcp-client.ts @@ -0,0 +1,146 @@ +/** + * A hand-rolled MCP client for the browser. + * + * The official `@modelcontextprotocol/client` validates `tools/call` responses against its own + * result schemas, which do not yet accept the tasks extension's `resultType: "task"` discriminator + * — so a task handle comes back as a validation error rather than a handle. Until that lands, + * talking raw JSON-RPC is the honest way to demo the extension, and it has the side benefit of + * showing exactly what a stateless MCP request looks like now. + */ +export const PROTOCOL_VERSION = "2026-07-28"; +export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; +export const MCP_ENDPOINT = "/api/mcp"; + +export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; + +export type WireTask = { + taskId: string; + status: TaskStatus; + statusMessage?: string; + createdAt: string; + lastUpdatedAt: string; + ttlMs: number | null; + pollIntervalMs?: number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +}; + +export const TERMINAL: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", +]); + +/** One direction of one JSON-RPC exchange, for the wire log. */ +export type Frame = { + id: number; + direction: "out" | "in"; + method: string; + payload: unknown; + at: number; +}; + +let frameId = 0; +let requestId = 0; + +export type RpcOptions = { + /** Called once for the request and once for the response, so the UI can render the wire. */ + onFrame?: (frame: Frame) => void; +}; + +/** + * Sends one stateless JSON-RPC request. + * + * There is no initialize handshake and no session header any more: the protocol version, who the + * client is, and which extensions it supports all ride in `_meta` on every single request. The + * server reads the capabilities from there to decide whether it may answer a tool call with a + * task handle. + */ +export async function rpc>( + method: string, + params: Record, + options: RpcOptions = {}, +): Promise { + const body = { + jsonrpc: "2.0" as const, + id: ++requestId, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { name: "mcp-tasks-demo", version: "0.1.0" }, + "io.modelcontextprotocol/clientCapabilities": { + extensions: { [TASKS_EXTENSION]: {} }, + }, + }, + }, + }; + + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": PROTOCOL_VERSION, + "mcp-method": method, + }; + // The spec routes on a name header so a load balancer never has to parse the body: the tool + // name for a call, the task id for the task methods. + if (method === "tools/call" && typeof params.name === "string") headers["mcp-name"] = params.name; + if (method.startsWith("tasks/") && typeof params.taskId === "string") { + headers["mcp-name"] = params.taskId; + } + + options.onFrame?.({ id: ++frameId, direction: "out", method, payload: body, at: Date.now() }); + + const response = await fetch(MCP_ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const message = await readMessage(response); + options.onFrame?.({ + id: ++frameId, + direction: "in", + method, + payload: message, + at: Date.now(), + }); + + if (message?.error) throw new RpcError(method, message.error); + if (!response.ok) throw new Error(`${method} failed with HTTP ${response.status}`); + return message?.result as T; +} + +export class RpcError extends Error { + constructor( + readonly method: string, + readonly rpcError: { code?: number; message?: string; data?: unknown }, + ) { + super(`${method}: ${rpcError?.message ?? "unknown error"}`); + this.name = "RpcError"; + } +} + +type JsonRpcResponse = { result?: unknown; error?: { code?: number; message?: string } } | undefined; + +/** + * Reads either shape the transport may answer with: a plain JSON body, or a one-message SSE + * stream when the server decides to stream the response. + */ +async function readMessage(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + if (!text) return undefined; + + if (!contentType.includes("text/event-stream")) { + return JSON.parse(text) as JsonRpcResponse; + } + + for (const line of text.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data) return JSON.parse(data) as JsonRpcResponse; + } + return undefined; +} diff --git a/examples/mcp-tasks-demo/app/lib/tasks.ts b/examples/mcp-tasks-demo/app/lib/tasks.ts new file mode 100644 index 0000000..e2bf9f2 --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/tasks.ts @@ -0,0 +1,77 @@ +/** + * The whole server-side wiring: a store, a dispatcher, and one task tool. + * + * Both routes import from here — `/api/mcp` to serve the protocol, `/api/execute` to run the work + * QStash delivers back. + */ +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +/** Where QStash delivers a task. It has to be reachable *from QStash*, not just from your browser. */ +export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute`; + +export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL, retries: 5 }); + +export const tasks = createTaskLayer({ + // Both default to `fromEnv()`, so there is no client to thread through. + store: new RedisTaskStore(), + dispatcher, + defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, +}); + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const STEPS = 4; + +/** + * Builds a server with the demo's task tool on it. + * + * A fresh one per request: the transport below is stateless, and an `McpServer` owns the single + * transport it is connected to. + */ +export function createServer(): McpServer { + const server = new McpServer( + { name: "upstash-mcp-tasks-demo", version: "0.1.0" }, + // Without this the transport validates the request's `mcp-protocol-version` header against + // the SDK's 2025-era list and rejects every 2026-07-28 request before it reaches a handler. + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + + tasks.registerTask( + server, + "generate_report", + { + title: "Generate report", + description: `Generates a report on a topic in ${STEPS} durable steps. Returns a task handle immediately.`, + inputSchema: z.object({ topic: z.string().describe("What the report should be about") }), + completedMessage: "Report ready", + }, + async ({ topic }, task) => { + for (let step = 1; step <= STEPS; step++) { + // Cancellation is cooperative: running code only stops where it checks, so the check + // goes at every step boundary. + if (await task.isCancelled()) { + console.log(`[execute] task=${task.taskId} cancelled before step ${step}`); + return {}; + } + await task.update(`Step ${step}/${STEPS}: processing ${topic}`); + await sleep(2_500); + } + + return { + content: [{ type: "text", text: `Report complete: ${topic}` }], + structuredContent: { report: `A concise report about ${topic}.` }, + }; + }, + ); + + return server; +} + +// The delivery endpoint receives only a task id and looks the handler up by the tool name stored +// on the task — so the handler registry has to be populated even when `/api/execute` is the first +// route hit in this process. Registering once at module load does that; the server built here is +// never connected to a transport. +createServer(); diff --git a/examples/mcp-tasks-demo/app/page.tsx b/examples/mcp-tasks-demo/app/page.tsx new file mode 100644 index 0000000..8eab887 --- /dev/null +++ b/examples/mcp-tasks-demo/app/page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + rpc, + TASKS_EXTENSION, + TERMINAL, + type Frame, + type WireTask, +} from "./lib/mcp-client"; + +const TOOL_NAME = "generate_report"; + +type TrackedTask = { + taskId: string; + topic: string; + startedAt: number; + lastPolledAt: number; + polls: number; + wire: WireTask; +}; + +export default function Page() { + const [topic, setTopic] = useState("coffee trends"); + const [tasks, setTasks] = useState([]); + const [frames, setFrames] = useState([]); + const [tools, setTools] = useState(null); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const [, setTick] = useState(0); + + const onFrame = useCallback((frame: Frame) => { + setFrames(previous => [frame, ...previous].slice(0, 80)); + }, []); + + // A plain `tools/list` — the task tool is an ordinary MCP tool. Nothing about its declaration + // says "task"; the server decides per call whether to answer with a handle. + useEffect(() => { + rpc<{ tools: { name: string }[] }>("tools/list", {}, { onFrame }) + .then(result => setTools(result.tools.map(tool => tool.name))) + .catch(cause => setError(String(cause))); + }, [onFrame]); + + // Re-render once a second so the elapsed counters move. + useEffect(() => { + const id = setInterval(() => setTick(n => n + 1), 1000); + return () => clearInterval(id); + }, []); + + const tasksRef = useRef(tasks); + tasksRef.current = tasks; + + // The client polls; the server has nothing to push. Each task carries its own + // `pollIntervalMs`, so the server sets the pace rather than the UI guessing. + useEffect(() => { + const id = setInterval(() => { + const now = Date.now(); + for (const task of tasksRef.current) { + if (TERMINAL.has(task.wire.status)) continue; + if (now - task.lastPolledAt < (task.wire.pollIntervalMs ?? 2000)) continue; + markPolled(task.taskId); + void poll(task.taskId); + } + }, 400); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function markPolled(taskId: string) { + setTasks(previous => + previous.map(task => (task.taskId === taskId ? { ...task, lastPolledAt: Date.now() } : task)), + ); + } + + async function poll(taskId: string) { + try { + const wire = await rpc("tasks/get", { taskId }, { onFrame }); + setTasks(previous => + previous.map(task => + task.taskId === taskId ? { ...task, wire, polls: task.polls + 1 } : task, + ), + ); + } catch (cause) { + setError(String(cause)); + } + } + + async function start(event: React.FormEvent) { + event.preventDefault(); + if (!topic.trim() || starting) return; + setStarting(true); + setError(null); + try { + // A normal `tools/call`. Because the request declared the tasks extension in its + // capabilities, the server answers with a handle instead of blocking for ten seconds. + const wire = await rpc( + "tools/call", + { name: TOOL_NAME, arguments: { topic: topic.trim() } }, + { onFrame }, + ); + setTasks(previous => [ + { + taskId: wire.taskId, + topic: topic.trim(), + startedAt: Date.now(), + lastPolledAt: Date.now(), + polls: 0, + wire, + }, + ...previous, + ]); + } catch (cause) { + setError(String(cause)); + } finally { + setStarting(false); + } + } + + async function cancel(taskId: string) { + try { + await rpc("tasks/cancel", { taskId }, { onFrame }); + await poll(taskId); + } catch (cause) { + setError(String(cause)); + } + } + + return ( + <> +
+

MCP Tasks on Upstash

+

+ A long-running MCP tool that returns a task handle instead of blocking. The task record + lives in Upstash Redis, the work runs through QStash, and this page is the client doing + nothing but stateless JSON-RPC — no initialize handshake, no session id. +

+
+ {TASKS_EXTENSION} + 2026-07-28 + + tools/list → {tools ? (tools.join(", ") || "none") : "…"} + +
+
+ + {error ?
{error}
: null} + +
+
+
+

Call the tool

+
+
+ setTopic(event.target.value)} + placeholder="a topic to report on" + aria-label="Report topic" + /> + +
+

+ generate_report takes four steps of about 2.5 + seconds and checks for cancellation between them. +

+
+
+ +
+ {tasks.length === 0 ? ( +

No tasks yet. Run the tool to create one.

+ ) : ( + tasks.map(task => ( + cancel(task.taskId)} /> + )) + )} +
+ +
+

Prove the work is durable, not just the record

+

+ Start a task, then kill the dev server mid-run and start it again. The Redis record + was never in doubt — but the QStash delivery that died with the process is retried + against the new one, so the task still reaches completed. Swap QStash for + a fire-and-forget promise and the same test leaves a perfectly durable record of a + task stuck in working until its TTL expires. +

+
+
+ +
+

+ Wire log + +

+
+ {frames.length === 0 ? ( +

+ Nothing sent yet. +

+ ) : ( + frames.map(frame => ) + )} +
+
+
+ + ); +} + +function TaskCard({ task, onCancel }: { task: TrackedTask; onCancel: () => void }) { + const { wire } = task; + const done = TERMINAL.has(wire.status); + const elapsed = Math.round(((done ? Date.parse(wire.lastUpdatedAt) : Date.now()) - task.startedAt) / 1000); + const progress = readProgress(wire); + + return ( +
+
+ {task.topic} + {wire.taskId.slice(0, 8)}… + + {wire.status} + {done ? null : ( + + )} +
+ +

{wire.statusMessage ?? "—"}

+ +
+ +
+ +
+ {elapsed}s elapsed + {task.polls} polls + ttl {wire.ttlMs === null ? "∞" : `${Math.round(wire.ttlMs / 1000)}s`} + every {wire.pollIntervalMs ?? 2000}ms +
+ + {wire.result ? ( +
+

Result, carried inline on the final poll:

+
{JSON.stringify(wire.result, null, 2)}
+
+ ) : null} + + {wire.error ? ( +
+

Failed:

+
{JSON.stringify(wire.error, null, 2)}
+
+ ) : null} +
+ ); +} + +function FrameRow({ frame }: { frame: Frame }) { + return ( +
+
+ + {frame.direction === "out" ? "→" : "←"} + + {frame.method} + {new Date(frame.at).toLocaleTimeString()} +
+
{JSON.stringify(frame.payload)}
+
+ ); +} + +/** The demo handler reports `Step n/4`, which is enough to drive a progress bar. */ +function readProgress(wire: WireTask): number { + if (wire.status === "completed") return 100; + const match = /Step (\d+)\/(\d+)/.exec(wire.statusMessage ?? ""); + if (!match) return wire.status === "working" ? 4 : 0; + const [, step, total] = match; + return Math.round((Number(step) / Number(total)) * 100); +} diff --git a/examples/mcp-tasks-demo/next-env.d.ts b/examples/mcp-tasks-demo/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/examples/mcp-tasks-demo/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/mcp-tasks-demo/next.config.ts b/examples/mcp-tasks-demo/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/examples/mcp-tasks-demo/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/examples/mcp-tasks-demo/package.json b/examples/mcp-tasks-demo/package.json new file mode 100644 index 0000000..bb648f8 --- /dev/null +++ b/examples/mcp-tasks-demo/package.json @@ -0,0 +1,28 @@ +{ + "name": "mcp-tasks-demo", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "qstash": "npx @upstash/qstash-cli dev", + "smoke": "node scripts/smoke.mjs" + }, + "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "@upstash/mcp-tasks": "workspace:*", + "@upstash/qstash": "^2.11.3", + "@upstash/redis": "^1.38.0", + "next": "16.2.9", + "react": "19.2.6", + "react-dom": "19.2.6", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", + "typescript": "^5" + } +} diff --git a/examples/mcp-tasks-demo/scripts/smoke.mjs b/examples/mcp-tasks-demo/scripts/smoke.mjs new file mode 100644 index 0000000..d675936 --- /dev/null +++ b/examples/mcp-tasks-demo/scripts/smoke.mjs @@ -0,0 +1,99 @@ +// Drives the demo the way the browser does: raw stateless JSON-RPC. +const BASE = process.env.BASE ?? "http://127.0.0.1:3000"; +const PV = "2026-07-28"; +const EXT = "io.modelcontextprotocol/tasks"; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +let id = 0; +async function rpc(method, params = {}, { caps = true } = {}) { + const headers = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": PV, + "mcp-method": method, + }; + if (params.name) headers["mcp-name"] = params.name; + if (params.taskId) headers["mcp-name"] = params.taskId; + const response = await fetch(`${BASE}/api/mcp`, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: ++id, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": PV, + "io.modelcontextprotocol/clientInfo": { name: "e2e", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": caps ? { extensions: { [EXT]: {} } } : {}, + }, + }, + }), + }); + const json = await response.json(); + if (json.error) throw new Error(`${method}: ${JSON.stringify(json.error)}`); + return json.result; +} + +const brief = t => + JSON.stringify({ + status: t.status, + statusMessage: t.statusMessage, + ...(t.result ? { result: t.result } : {}), + }); + +console.log("== tools/list =="); +const list = await rpc("tools/list"); +console.log(list.tools.map(t => t.name).join(", ")); + +console.log("\n== 1. happy path =="); +const created = await rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee trends" }, +}); +console.log("created", JSON.stringify(created)); +if (created.resultType !== "task") throw new Error("expected a task handle"); + +let last; +for (let i = 0; i < 20; i++) { + await sleep(1500); + last = await rpc("tasks/get", { taskId: created.taskId }); + console.log("poll ", brief(last)); + if (["completed", "failed", "cancelled"].includes(last.status)) break; +} +if (last.status !== "completed") throw new Error(`expected completed, got ${last.status}`); + +console.log("\n== 2. cancel mid-flight =="); +const second = await rpc("tools/call", { + name: "generate_report", + arguments: { topic: "tea rituals" }, +}); +console.log("created", second.taskId); +await sleep(3000); +const cancelled = await rpc("tasks/cancel", { taskId: second.taskId }); +console.log("cancel", brief(cancelled)); +await sleep(6000); +const afterCancel = await rpc("tasks/get", { taskId: second.taskId }); +console.log("after ", brief(afterCancel)); +if (afterCancel.status !== "cancelled") throw new Error(`expected cancelled, got ${afterCancel.status}`); +if (afterCancel.result) throw new Error("a cancelled task must not carry a result"); + +console.log("\n== 3. client without the tasks capability =="); +const refused = await rpc( + "tools/call", + { name: "generate_report", arguments: { topic: "nope" } }, + { caps: false }, +); +console.log("refused", JSON.stringify(refused)); +if (!refused.isError) throw new Error("expected a tool error"); + +console.log("\n== 4. unknown task =="); +try { + await rpc("tasks/get", { taskId: "does-not-exist" }); + throw new Error("expected an error"); +} catch (e) { + console.log("errored as expected:", e.message.slice(0, 120)); +} + +console.log("\nALL E2E CHECKS PASSED"); diff --git a/examples/mcp-tasks-demo/tsconfig.json b/examples/mcp-tasks-demo/tsconfig.json new file mode 100644 index 0000000..803331c --- /dev/null +++ b/examples/mcp-tasks-demo/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md new file mode 100644 index 0000000..52354fa --- /dev/null +++ b/packages/mcp-tasks/README.md @@ -0,0 +1,222 @@ +# @upstash/mcp-tasks + +A durable [MCP Tasks](https://github.com/modelcontextprotocol/ext-tasks) runtime for the official +TypeScript SDK, with Upstash Redis and QStash as the backends. + +The 2026-07-28 MCP spec made the protocol stateless: no `initialize` handshake, no `Mcp-Session-Id`, +every request carrying its own protocol version, client identity and capabilities in `_meta`. Long +running tools got the Tasks extension — a tool call answers with a task handle and the client polls +for the result. The official TypeScript SDK v2 ships the wire schemas for it but **no tasks +runtime**; the v1 experimental task APIs were removed with no migration path. + +This package is that runtime. It is one factory over two interfaces, so the storage and the +execution transport are yours to choose: + +| Layer | Interface | What it has to guarantee | What ships here | +| --- | --- | --- | --- | +| Task record | `TaskStore` | Durable create before the response, TTL cleanup | Upstash Redis hash + `PEXPIRE` | +| Execution | `TaskDispatcher` | At-least-once delivery that survives a dead process, cancellable while pending | QStash publish to your execute endpoint | +| Polling | — | `tasks/get` reads the store | built in | + +## Why two interfaces and not one + +A durable task ID does not make the underlying work durable. Write the record to shared storage and +then run the work in a fire-and-forget promise, and a deploy mid-task leaves you with a perfectly +durable record of a task stuck in `working` until its TTL expires. The record and the work are +separate problems, so they get separate seams. + +## Install + +```bash +npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash +``` + +`@upstash/redis` and `@upstash/qstash` are only needed for the Upstash backends, which live behind +the `@upstash/mcp-tasks/upstash` entry point. Bring your own store and the root import pulls +neither. + +## Usage + +```ts +import { McpServer, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +const tasks = createTaskLayer({ + store: new RedisTaskStore(), // optional: { redis, prefix, enableTelemetry } + dispatcher: new QStashDispatcher({ + url: `${process.env.APP_URL}/api/execute`, // where QStash delivers the task + retries: 3, // optional: attempts before giving up + }), + defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, // optional +}); + +export function createServer() { + const server = new McpServer( + { name: "reports", version: "1.0.0" }, + // Required: the transport otherwise rejects 2026-07-28 requests as an unsupported version. + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + + tasks.registerTask( + server, + "generate_report", + { + description: "Generates a report in four durable steps", + inputSchema: z.object({ topic: z.string() }), + ttlMs: 300_000, // optional: retention, null for unlimited + pollIntervalMs: 2_000, // optional: what to suggest to the client + }, + async ({ topic }, task) => { + for (let step = 1; step <= 4; step++) { + if (await task.isCancelled()) return {}; + await task.update(`Step ${step}/4: processing ${topic}`); + await doWork(topic, step); + } + return { content: [{ type: "text", text: `Report complete: ${topic}` }] }; + }, + ); + + return server; +} +``` + +Then two endpoints — the MCP transport, and the one QStash delivers to: + +```ts +// POST /api/mcp +const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, +}); +await createServer().connect(transport); +return transport.handleRequest(request); +``` + +```ts +// POST /api/execute +import { Receiver } from "@upstash/qstash"; +import { isFinalQStashAttempt } from "@upstash/mcp-tasks/upstash"; + +const body = await request.text(); +await receiver.verify({ signature: request.headers.get("upstash-signature")!, body, url: EXECUTE_URL }); + +try { + await tasks.executeTask(JSON.parse(body).taskId, { + isFinalAttempt: isFinalQStashAttempt(request.headers, dispatcher.retries), + }); + return new Response("ok"); +} catch { + return new Response("retry", { status: 500 }); // non-2xx asks QStash to retry +} +``` + +The `Receiver` check is not optional in production: without it anyone who can reach the route can +run tasks. + +## What the client sees + +```jsonc +// tools/call → a handle, immediately +{ "resultType": "task", "taskId": "0e30…", "status": "working", + "statusMessage": "Queued for durable execution", "ttlMs": 300000, "pollIntervalMs": 2000 } + +// tasks/get → progress, then the result inline +{ "resultType": "complete", "taskId": "0e30…", "status": "working", "statusMessage": "Step 3/4: …" } +{ "resultType": "complete", "taskId": "0e30…", "status": "completed", "statusMessage": "Completed", + "result": { "content": [{ "type": "text", "text": "Report complete: coffee trends" }] } } +``` + +Five states — `working`, `input_required`, `completed`, `failed`, `cancelled` — of which the last +three are terminal and never change again. + +## Design notes + +Four things here are deliberate, and three of them differ from the obvious implementation. + +**The record is written before the handle goes out.** The spec requires it: the client may +`tasks/get` the id against another instance the moment it has it. So `registerTask` creates, then +dispatches, then responds — never the other way around. + +**Terminal transitions go through `settle`, not `update`.** Two writers race for the end of a task +by design — a client's `tasks/cancel` and the executor finishing at the same moment. `settle` moves +a task to a terminal state *only if it is not terminal already*, atomically (a Lua script on Redis), +and returns `null` when it lost. A check-then-write would let a late `completed` overwrite a +`cancelled`; this cannot. The store also keeps one field per task property rather than one JSON +blob, so a progress update and a cancel never clobber each other's fields. + +**A failed attempt is not automatically a failed task.** Settling `failed` on the first error makes +the task terminal, and every subsequent redelivery then short-circuits on the redelivery guard — so +QStash's retries would be silently useless. `executeTask(id, { isFinalAttempt })` is what +distinguishes them: before the last attempt the task stays `working` and the error is rethrown so +your endpoint can answer non-2xx; on the last one it settles `failed`. `isFinalQStashAttempt` reads +that from the `Upstash-Retried` header. + +**Redelivery is expected, not exceptional.** At-least-once is the strongest thing a queue promises, +so `executeTask` returns early on an already-terminal task. + +## Two gotchas in the official SDK + +Both verified against `@modelcontextprotocol/server@2.0.0`, and both are why this package exists in +the shape it does. + +**`createMcpHandler` cannot serve `tasks/get` / `tasks/cancel`.** It pins each request to the +2026-07-28 era from the client's envelope claim, and on that era the SDK's dispatch gate answers +those two methods with `-32601` *before* looking up your handler — they are claimed spec vocabulary +in its 2025 registry and were dropped from the 2026 one, so they are neither dispatchable nor +free-form. Either serve with `WebStandardStreamableHTTPServerTransport` (or the Node one) and +`transport.handleRequest`, which stays on the 2025 era where they dispatch normally — the per-request +`_meta` envelope is still lifted, so nothing else changes — or keep `createMcpHandler` and move the +operations to your own namespace: + +```ts +createTaskLayer({ store, dispatcher, methods: { get: "upstash/tasks.get", cancel: "upstash/tasks.cancel" } }); +``` + +**A tool callback cannot return a JSON-RPC error.** `McpServer` catches everything a tool callback +throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and flattens it into +`{ content, isError: true }`, dropping the code. So a client that has not declared the tasks +capability gets a structured tool error instead, with the code and the capability it is missing in +`structuredContent`: + +```jsonc +{ "isError": true, + "content": [{ "type": "text", "text": "\"generate_report\" answers with a task handle, which requires …" }], + "structuredContent": { "code": -32021, + "requiredCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } } } +``` + +Pass `onMissingCapability: "run-inline"` to run the handler and answer normally instead — spec-legal, +since the server chooses per call, but it brings back the blocking request tasks exist to avoid. + +## Bringing your own backend + +Implement `TaskStore` (four methods) and `TaskDispatcher` (two), and the core does not change. A +Postgres store is the same methods over one table with a cleanup job standing in for `PEXPIRE`; a +BullMQ dispatcher is an `add` returning the job id and a `remove` for cancel. +`MemoryTaskStore` + `InlineTaskDispatcher` ship for tests and local runs — neither is durable, which +is exactly the failure this package is about. + +## API + +| Export | What it is | +| --- | --- | +| `createTaskLayer(options)` | The runtime: `{ registerTask, executeTask, getTask, store, dispatcher }` | +| `TaskStore`, `TaskDispatcher`, `TaskContext` | The two seams and what a handler is handed | +| `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset that goes on the wire | +| `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | +| `TASKS_EXTENSION`, `TASKS_PROTOCOL_VERSION`, `TASK_METHODS` | The extension id, `"2026-07-28"`, the method names | +| `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | +| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `isFinalQStashAttempt` | + +## Not implemented + +`tasks/update` (the client answering an `input_required` task) and `tasks/list`. The latter is +absent from the spec on purpose — without sessions a server cannot scope a list to one caller +without leaking that other people's tasks exist. `tasks/update` would follow the same shape as the +other two: write the client's answer into the record, and let the handler read it at a step +boundary, exactly as it reads the cancelled status today. + +The `ext-tasks` repo labels itself experimental and its schema is a draft, so these wire shapes may +change before Tasks lands in core. diff --git a/packages/mcp-tasks/package.json b/packages/mcp-tasks/package.json new file mode 100644 index 0000000..0a603a8 --- /dev/null +++ b/packages/mcp-tasks/package.json @@ -0,0 +1,74 @@ +{ + "name": "@upstash/mcp-tasks", + "version": "0.1.0", + "description": "A durable MCP Tasks runtime for the official TypeScript SDK: a pluggable task store and dispatcher, with Upstash Redis and QStash backends.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/upstash/agentkit.git", + "directory": "packages/mcp-tasks" + }, + "homepage": "https://github.com/upstash/agentkit/tree/main/packages/mcp-tasks", + "bugs": { + "url": "https://github.com/upstash/agentkit/issues" + }, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./upstash": { + "types": "./dist/upstash.d.ts", + "import": "./dist/upstash.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "upstash", + "redis", + "qstash", + "mcp", + "model-context-protocol", + "tasks", + "durable", + "long-running", + "ai", + "agent" + ], + "dependencies": { + "zod": "^4.2.0" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "@upstash/qstash": ">=2.11.0", + "@upstash/redis": ">=1.38.0" + }, + "peerDependenciesMeta": { + "@upstash/qstash": { + "optional": true + }, + "@upstash/redis": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@upstash/qstash": "^2.11.3", + "@upstash/redis": "^1.38.0", + "dotenv": "^16.4.5" + } +} diff --git a/packages/mcp-tasks/src/core.test.ts b/packages/mcp-tasks/src/core.test.ts new file mode 100644 index 0000000..2e60f1d --- /dev/null +++ b/packages/mcp-tasks/src/core.test.ts @@ -0,0 +1,412 @@ +/** + * The runtime, exercised through a real `McpServer` and a real transport — the requests below are + * genuine JSON-RPC over the wire, not direct calls into the layer. + */ +import { McpServer, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; +import * as z from "zod"; +import { createTaskLayer, TASKS_EXTENSION, TASKS_PROTOCOL_VERSION } from "./core.js"; +import { InlineTaskDispatcher, MemoryTaskStore } from "./memory.js"; +import type { TaskContext, TaskLayer, WireTask } from "./index.js"; +import { sleep } from "./test-support.js"; + +type Rpc = ( + method: string, + params?: Record, + options?: { withTasksCapability?: boolean }, +) => Promise<{ result?: Record; error?: { code: number; message: string } }>; + +type Harness = { + rpc: Rpc; + tasks: TaskLayer; + store: MemoryTaskStore; + dispatcher: InlineTaskDispatcher; + close: () => Promise; +}; + +/** Builds a server with one task tool backed by `handler`. */ +async function harness( + handler: (args: { topic: string }, task: TaskContext) => Promise>, + layerOptions: Partial[0]> & { + /** What the auto-dispatch reports as the attempt's finality. Defaults to true. */ + dispatchIsFinalAttempt?: boolean; + } = {}, +): Promise { + const { dispatchIsFinalAttempt = true, ...layer } = layerOptions; + const store = new MemoryTaskStore(); + // Bound below, once the layer exists. + let execute: (taskId: string) => Promise = async () => undefined; + const dispatcher = new InlineTaskDispatcher((taskId) => execute(taskId)); + + const tasks = createTaskLayer({ store, dispatcher, ...layer }); + execute = (taskId) => tasks.executeTask(taskId, { isFinalAttempt: dispatchIsFinalAttempt }); + + // The transport validates the request's `mcp-protocol-version` header against this list, which + // otherwise defaults to the 2025-era versions and rejects every 2026-07-28 request. + const server = new McpServer( + { name: "test", version: "1.0.0" }, + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + tasks.registerTask( + server, + "generate_report", + { description: "Generates a report", inputSchema: z.object({ topic: z.string() }) }, + handler, + ); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + await server.connect(transport); + + let id = 0; + const rpc: Rpc = async (method, params = {}, options = {}) => { + const { withTasksCapability = true } = options; + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": TASKS_PROTOCOL_VERSION, + "mcp-method": method, + }; + if (typeof params.name === "string") headers["mcp-name"] = params.name; + if (typeof params.taskId === "string") headers["mcp-name"] = params.taskId; + + const response = await transport.handleRequest( + new Request("http://localhost/mcp", { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: ++id, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": TASKS_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { name: "test", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": withTasksCapability + ? { extensions: { [TASKS_EXTENSION]: {} } } + : {}, + }, + }, + }), + }), + ); + return JSON.parse(await response.text()); + }; + + return { + rpc, + tasks, + store, + dispatcher, + close: async () => { + await transport.close(); + store.clear(); + }, + }; +} + +/** A four-step handler that cooperates with cancellation, like the demo's. */ +const steppedHandler = + (steps = 4, stepMs = 20) => + async ({ topic }: { topic: string }, task: TaskContext) => { + for (let step = 1; step <= steps; step++) { + if (await task.isCancelled()) return {}; + await task.update(`Step ${step}/${steps}: processing ${topic}`); + await sleep(stepMs); + } + return { content: [{ type: "text", text: `Report complete: ${topic}` }] }; + }; + +describe("createTaskLayer over MCP", () => { + let live: Harness | undefined; + afterEach(async () => { + await live?.close(); + live = undefined; + }); + + it("answers tools/call with a task handle and no result", async () => { + live = await harness(steppedHandler()); + const { result, error } = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee trends" }, + }); + + expect(error).toBeUndefined(); + expect(result?.resultType).toBe("task"); + expect(result?.status).toBe("working"); + expect(result?.statusMessage).toBe("Queued for durable execution"); + expect(typeof result?.taskId).toBe("string"); + expect(result?.ttlMs).toBe(300_000); + expect(result?.pollIntervalMs).toBe(2_000); + // The wire object must never leak the server's own bookkeeping. + expect(result).not.toHaveProperty("name"); + expect(result).not.toHaveProperty("args"); + expect(result).not.toHaveProperty("dispatchId"); + }); + + it("has the task durably readable the instant the handle is returned", async () => { + live = await harness(steppedHandler()); + const { result } = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee trends" }, + }); + // No awaiting, no sleeping: the create must have committed before the response went out. + const stored = await live.store.get(String(result?.taskId)); + expect(stored?.taskId).toBe(result?.taskId); + expect(stored?.name).toBe("generate_report"); + expect(stored?.args).toEqual({ topic: "coffee trends" }); + }); + + it("rejects a client that has not declared the tasks extension", async () => { + live = await harness(steppedHandler()); + const { result } = await live.rpc( + "tools/call", + { name: "generate_report", arguments: { topic: "tea" } }, + { withTasksCapability: false }, + ); + // McpServer flattens anything a tool callback throws into an isError result and drops the + // code, so the refusal is a structured tool error rather than a JSON-RPC one. + expect(result?.isError).toBe(true); + expect(result?.resultType).not.toBe("task"); + expect(result?.structuredContent).toEqual({ + code: -32021, + requiredCapabilities: { extensions: { "io.modelcontextprotocol/tasks": {} } }, + }); + expect(String((result?.content as { text: string }[])[0]?.text)).toMatch(/capability/i); + // Nothing was created or dispatched for a call that was refused. + expect(live.dispatcher.dispatched).toBe(0); + }); + + it("runs inline for such a client when configured to", async () => { + live = await harness(steppedHandler(1, 1), { onMissingCapability: "run-inline" }); + const { result, error } = await live.rpc( + "tools/call", + { name: "generate_report", arguments: { topic: "tea" } }, + { withTasksCapability: false }, + ); + expect(error).toBeUndefined(); + expect(result?.resultType).not.toBe("task"); + expect(result?.content).toEqual([{ type: "text", text: "Report complete: tea" }]); + }); + + it("polls through to a completed task carrying its result inline", async () => { + live = await harness(steppedHandler()); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee trends" }, + }); + const taskId = String(created.result?.taskId); + + await live.dispatcher.drain(); + + const polled = await live.rpc("tasks/get", { taskId }); + expect(polled.result?.resultType).toBe("complete"); + expect(polled.result?.status).toBe("completed"); + expect(polled.result?.statusMessage).toBe("Completed"); + expect(polled.result?.result).toEqual({ + content: [{ type: "text", text: "Report complete: coffee trends" }], + }); + }); + + it("reports progress between steps", async () => { + live = await harness(steppedHandler(4, 40)); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee trends" }, + }); + const taskId = String(created.result?.taskId); + + await sleep(50); + const midway = await live.rpc("tasks/get", { taskId }); + expect(midway.result?.status).toBe("working"); + expect(String(midway.result?.statusMessage)).toMatch(/^Step \d\/4: processing coffee trends$/); + + await live.dispatcher.drain(); + }); + + it("errors with -32602 for an unknown task id", async () => { + live = await harness(steppedHandler()); + const { error } = await live.rpc("tasks/get", { taskId: "nope" }); + expect(error?.code).toBe(-32602); + expect(error?.message).toMatch(/Unknown task/); + }); + + describe("cancellation", () => { + it("flips the task to cancelled and stops the handler at its next check", async () => { + live = await harness(steppedHandler(4, 60)); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee trends" }, + }); + const taskId = String(created.result?.taskId); + + await sleep(70); + const cancelled = await live.rpc("tasks/cancel", { taskId }); + expect(cancelled.result?.resultType).toBe("complete"); + expect(cancelled.result?.status).toBe("cancelled"); + + await live.dispatcher.drain(); + + // The handler ran on past the cancel and returned, but a terminal state is final: its + // completion must not have overwritten the cancellation. + const after = await live.rpc("tasks/get", { taskId }); + expect(after.result?.status).toBe("cancelled"); + expect(after.result?.result).toBeUndefined(); + }); + + it("is idempotent", async () => { + live = await harness(steppedHandler(4, 30)); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + + await live.rpc("tasks/cancel", { taskId }); + const second = await live.rpc("tasks/cancel", { taskId }); + expect(second.error).toBeUndefined(); + expect(second.result?.status).toBe("cancelled"); + await live.dispatcher.drain(); + }); + + it("never lets a completion overwrite a cancellation that landed first", async () => { + // The race, made deterministic: the handler finishes its work, and the cancel arrives while + // it is between finishing and being settled. + live = await harness(async (_args, task) => { + await live!.store.settle(task.taskId, { status: "cancelled" }); + return { content: [{ type: "text", text: "too late" }] }; + }); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + await live.dispatcher.drain(); + + const after = await live.rpc("tasks/get", { taskId }); + expect(after.result?.status).toBe("cancelled"); + expect(after.result?.result).toBeUndefined(); + }); + }); + + describe("at-least-once delivery", () => { + it("ignores a redelivery of a task that already finished", async () => { + let runs = 0; + live = await harness(async () => { + runs += 1; + return { content: [{ type: "text", text: "done" }] }; + }); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + await live.dispatcher.drain(); + expect(runs).toBe(1); + + // The same message arriving twice is the contract, not a bug. + await live.tasks.executeTask(taskId); + await live.tasks.executeTask(taskId); + expect(runs).toBe(1); + }); + + it("keeps a task retryable until the dispatcher's last attempt", async () => { + let attempts = 0; + live = await harness( + async () => { + attempts += 1; + if (attempts < 3) throw new Error(`boom ${attempts}`); + return { content: [{ type: "text", text: "eventually" }] }; + }, + { dispatchIsFinalAttempt: false }, + ); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + await live.dispatcher.drain(); + + // Attempt 1 failed but must have left the task non-terminal, or the retries below would + // all short-circuit on the redelivery guard. + let current = await live.rpc("tasks/get", { taskId }); + expect(current.result?.status).toBe("working"); + + await expect(live.tasks.executeTask(taskId, { isFinalAttempt: false })).rejects.toThrow( + "boom 2", + ); + expect((await live.rpc("tasks/get", { taskId })).result?.status).toBe("working"); + + await live.tasks.executeTask(taskId, { isFinalAttempt: false }); + current = await live.rpc("tasks/get", { taskId }); + expect(current.result?.status).toBe("completed"); + expect(attempts).toBe(3); + }); + + it("settles failed on the final attempt", async () => { + live = await harness(async () => { + throw new Error("permanent"); + }); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + await live.dispatcher.drain(); + + const after = await live.rpc("tasks/get", { taskId }); + expect(after.result?.status).toBe("failed"); + expect(after.result?.statusMessage).toBe("Execution failed"); + expect(after.result?.error).toMatchObject({ code: -32603, message: "permanent" }); + }); + }); + + it("serves the task methods under custom names when asked", async () => { + live = await harness(steppedHandler(1, 1), { + methods: { get: "upstash/tasks.get", cancel: "upstash/tasks.cancel" }, + }); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + await live.dispatcher.drain(); + + expect((await live.rpc("tasks/get", { taskId })).error?.code).toBe(-32601); + const custom = await live.rpc("upstash/tasks.get", { taskId }); + expect(custom.result?.status).toBe("completed"); + }); + + it("infers handler argument types from the input schema", async () => { + // A compile-time assertion as much as a runtime one: `topic` is a string here because the + // schema said so, with no annotation on the handler. + live = await harness(async (args) => ({ + content: [{ type: "text", text: args.topic.toUpperCase() }], + })); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "coffee" }, + }); + await live.dispatcher.drain(); + const after = await live.rpc("tasks/get", { taskId: String(created.result?.taskId) }); + expect((after.result?.result as { content: { text: string }[] }).content[0]?.text).toBe( + "COFFEE", + ); + }); +}); + +describe("wire shape", () => { + it("keeps a WireTask assignable from what tasks/get returns", () => { + const wire: WireTask = { + taskId: "t", + status: "working", + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + ttlMs: null, + }; + expect(wire.ttlMs).toBeNull(); + }); +}); diff --git a/packages/mcp-tasks/src/core.ts b/packages/mcp-tasks/src/core.ts new file mode 100644 index 0000000..7d538c0 --- /dev/null +++ b/packages/mcp-tasks/src/core.ts @@ -0,0 +1,411 @@ +/** + * The tasks runtime. + * + * The official TypeScript SDK ships the 2026-07-28 wire schemas for tasks but no runtime to back + * them: v2 removed the v1 experimental task APIs and the migration guide says to drop the usages + * rather than port them. What it does give us is the seam — the low-level `setRequestHandler` + * takes a custom method plus your own schemas — so `tasks/get` and `tasks/cancel` can be added + * without forking anything. + * + * {@link createTaskLayer} is that runtime, in one factory over a {@link TaskStore} and a + * {@link TaskDispatcher}. + */ +import { randomUUID } from "node:crypto"; +import { + CLIENT_CAPABILITIES_META_KEY, + ProtocolError, + ProtocolErrorCode, + type McpServer, + type StandardSchemaWithJSON, +} from "@modelcontextprotocol/server"; +import * as z from "zod"; +import { + isTerminal, + UnknownTaskError, + type Task, + type TaskContext, + type TaskDispatcher, + type TaskStore, + type WireTask, +} from "./types.js"; + +/** The extension this runtime implements. */ +export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; + +/** The protocol revision that carries per-request capabilities and the tasks extension. */ +export const TASKS_PROTOCOL_VERSION = "2026-07-28"; + +const DEFAULT_TTL_MS = 300_000; +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +/** What to do when a client calls a task tool without declaring the tasks extension. */ +export type MissingCapabilityBehavior = + /** + * Answer with a tool error naming the capability the client has to declare, carrying `-32021` + * and the required capabilities in `structuredContent`. The default. + */ + | "error" + /** + * Run the handler inline and answer with its result, as an ordinary tool call. Spec-legal — the + * server chooses per call whether to return a handle — but it re-introduces exactly the blocking + * request that tasks exist to avoid, so it only suits work that is merely slow, not long. + */ + | "run-inline"; + +export type TaskLayerOptions = { + /** Durable storage for the task record. */ + store: TaskStore; + /** Durable transport for the work itself. */ + dispatcher: TaskDispatcher; + /** Fallback values for tasks that do not set their own. */ + defaults?: { + /** Retention window. `null` means unlimited. Defaults to 5 minutes. */ + ttlMs?: number | null; + /** Suggested client poll interval. Defaults to 2s. */ + pollIntervalMs?: number; + }; + /** How to answer a client that has not declared the extension. Defaults to `"error"`. */ + onMissingCapability?: MissingCapabilityBehavior; + /** + * The JSON-RPC method names to serve the task operations under. Defaults to the spec's + * `tasks/get` and `tasks/cancel`. + * + * Override them only if you serve through `createMcpHandler`. See {@link TASK_METHODS} for why + * the defaults cannot work there. + */ + methods?: { + get?: string; + cancel?: string; + }; +}; + +/** + * The spec method names, and the one deployment that cannot use them. + * + * `createMcpHandler` pins each request to the 2026-07-28 era from the client's envelope claim. + * On that era the SDK's dispatch gate rejects `tasks/*` with `-32601` *before* looking up your + * handler: those strings are claimed spec vocabulary (they are in the SDK's 2025 method registry) + * and were dropped from the 2026 one, so they are neither dispatchable nor free-form. Verified + * against the real handler — the registered handler never runs. + * + * Two ways out, both supported here: + * + * 1. Serve with `WebStandardStreamableHTTPServerTransport` (or `NodeStreamableHTTPServerTransport`) + * and `transport.handleRequest`. That path leaves the instance on the 2025 era, where + * `tasks/get` and `tasks/cancel` dispatch normally — the per-request `_meta` envelope is still + * lifted, so the capability check works exactly the same. This is what the demo does, and it is + * the default. + * 2. Stay on `createMcpHandler` and pass `methods` to move the operations to a namespace of your + * own (`{ get: "upstash/tasks.get", cancel: "upstash/tasks.cancel" }`). Anything outside the + * SDK's two registries is treated as a consumer-owned extension method and dispatches + * unconditionally — at the cost of no longer being the spec's wire names. + */ +export const TASK_METHODS = { get: "tasks/get", cancel: "tasks/cancel" } as const; + +export type TaskToolConfig = { + /** Human-readable title for `tools/list`. */ + title?: string; + /** What the tool does, for the model. */ + description: string; + /** A Standard Schema (Zod 4, ArkType, Valibot) describing the tool's arguments. */ + inputSchema: Schema; + /** Retention window for this tool's tasks. `null` means unlimited. */ + ttlMs?: number | null; + /** Poll interval to suggest for this tool's tasks. */ + pollIntervalMs?: number; + /** Status message set at creation. Defaults to `"Queued for durable execution"`. */ + queuedMessage?: string; + /** Status message set on success. Defaults to `"Completed"`. */ + completedMessage?: string; +}; + +/** Infers a Standard Schema's parsed output type. */ +type InferArgs = Schema extends { + readonly "~standard": { types?: { readonly output: infer Output } | undefined }; +} + ? Output + : unknown; + +export type TaskHandler = (args: Args, task: TaskContext) => Promise>; + +export type ExecuteTaskOptions = { + /** + * Whether this is the dispatcher's last delivery attempt. Defaults to `true`. + * + * It decides what a thrown handler means. On the last attempt the task is settled `failed`, + * which is terminal and final. Before then the task is deliberately *left* `working` and the + * error rethrown, so the endpoint can answer non-2xx and the dispatcher can retry — settling + * `failed` on the first error would make the task terminal and quietly turn every subsequent + * redelivery into a no-op, which is the opposite of what retries are for. + */ + isFinalAttempt?: boolean; +}; + +export type TaskLayer = { + /** Registers a tool whose calls are answered with a task handle. */ + registerTask( + server: McpServer, + name: string, + config: TaskToolConfig, + handler: TaskHandler>, + ): void; + /** Runs a dispatched task. Call this from the endpoint your dispatcher delivers to. */ + executeTask(taskId: string, options?: ExecuteTaskOptions): Promise; + /** Reads a task record server-side, bypassing the protocol. */ + getTask(taskId: string): Promise; + /** The store this layer was built on. */ + store: TaskStore; + /** The dispatcher this layer was built on. */ + dispatcher: TaskDispatcher; +}; + +/** + * Builds a tasks runtime over a store and a dispatcher. + * + * ```ts + * const tasks = createTaskLayer({ + * store: new RedisTaskStore(), + * dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + * }); + * ``` + */ +export function createTaskLayer(options: TaskLayerOptions): TaskLayer { + const { store, dispatcher, defaults = {}, onMissingCapability = "error" } = options; + const methods = { + get: options.methods?.get ?? TASK_METHODS.get, + cancel: options.methods?.cancel ?? TASK_METHODS.cancel, + }; + + // Keyed by tool name: the delivery endpoint only receives a task id, so it looks the handler up + // from the name recorded on the task. + const handlers = new Map>(); + const completedMessages = new Map(); + const wired = new WeakSet(); + + function registerTask( + server: McpServer, + name: string, + config: TaskToolConfig, + handler: TaskHandler>, + ): void { + handlers.set(name, handler as TaskHandler); + if (config.completedMessage) completedMessages.set(name, config.completedMessage); + wireTaskMethods(server); + + // The tool declares nothing task-specific: it is an ordinary MCP tool, and the decision to + // answer with a handle is made per call, from the caller's capabilities. + const callback = async (args: unknown, context: unknown): Promise> => { + { + if (!clientSupportsTasks(context)) { + if (onMissingCapability === "error") return missingCapabilityResult(name); + return await runInline(name, args); + } + + const now = new Date().toISOString(); + const task: Task = { + taskId: randomUUID(), + status: "working", + statusMessage: config.queuedMessage ?? "Queued for durable execution", + createdAt: now, + lastUpdatedAt: now, + ttlMs: config.ttlMs ?? defaults.ttlMs ?? DEFAULT_TTL_MS, + pollIntervalMs: + config.pollIntervalMs ?? defaults.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + name, + args, + }; + + // The order is the spec's, not a preference: the record must be durable before the + // handle goes out, because the client may `tasks/get` it against another instance the + // moment it has the id. Dispatch second, so a queue that accepts a task can always find + // its record. + await store.create(task); + const dispatchId = await dispatcher.dispatch(task.taskId); + const saved = dispatchId ? await store.update(task.taskId, { dispatchId }) : task; + + // The SDK's own result types strip `resultType` (it is wire-only), so the discriminator + // has to be asserted past them. The transport does emit it — verified end to end. + return { resultType: "task", ...toWire(saved) }; + } + }; + + server.registerTool( + name, + { title: config.title, description: config.description, inputSchema: config.inputSchema }, + callback as never, + ); + } + + /** + * Adds the extension's request methods to a server, once. `tasks/update` is deliberately absent: + * it answers an `input_required` task, and this runtime has no way for a handler to ask for + * input yet. It would be the same shape — write the client's answer into the record, let the + * handler read it at a step boundary, exactly as it reads the cancelled status. + */ + function wireTaskMethods(server: McpServer): void { + if (wired.has(server)) return; + wired.add(server); + + server.server.registerCapabilities({ extensions: { [TASKS_EXTENSION]: {} } }); + const params = z.object({ taskId: z.string() }); + + server.server.setRequestHandler(methods.get, { params }, async ({ taskId }) => ({ + resultType: "complete", + ...toWire(await required(taskId)), + })); + + server.server.setRequestHandler(methods.cancel, { params }, async ({ taskId }) => { + const task = await required(taskId); + // Two writes, on purpose. Flipping the status is the terminal, idempotent half — `settle` + // returns null when the task was already terminal, which makes a repeated cancel a no-op + // rather than a state change. Cancelling the dispatch is the other half: without it a + // pending retry would re-invoke the executor on a task that is already finished. + const settled = await store.settle(taskId, { + status: "cancelled", + statusMessage: "Cancelled by client", + }); + const dispatchId = settled?.dispatchId ?? task.dispatchId; + if (dispatchId) { + // A message already in flight cannot be recalled; that is why the spec calls + // cancellation cooperative, and why the handler still checks `isCancelled()`. + await dispatcher.cancel(dispatchId).catch(() => undefined); + } + return { resultType: "complete", ...toWire(settled ?? task) }; + }); + } + + async function executeTask( + taskId: string, + executeOptions: ExecuteTaskOptions = {}, + ): Promise { + const { isFinalAttempt = true } = executeOptions; + const task = await required(taskId); + + // The redelivery guard. Delivery is at-least-once by contract, so the same task id can arrive + // twice — after a cancel, or after a retry of a delivery that actually succeeded. + if (isTerminal(task.status)) return task; + + const handler = handlers.get(task.name); + if (!handler) { + throw new Error( + `No task handler registered for "${task.name}". Register it on every instance that serves the execute endpoint.`, + ); + } + + const context: TaskContext = { + taskId, + update: async (statusMessage) => { + await store.update(taskId, { statusMessage }); + }, + isCancelled: async () => { + const current = await store.get(taskId); + // A task that expired out from under us is not worth finishing either. + return current === null || current.status === "cancelled"; + }, + }; + + try { + const result = await (handler as TaskHandler)(task.args, context); + // If a cancel landed while the handler was running, `settle` refuses the transition and + // returns null — the cancelled status wins, with no check-then-write race of our own. + const settled = await store.settle(taskId, { + status: "completed", + statusMessage: completedMessages.get(task.name) ?? "Completed", + result, + }); + return settled ?? (await store.get(taskId)); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + if (!isFinalAttempt) { + // Stay non-terminal so the dispatcher's retry can still finish the work. + await store + .update(taskId, { statusMessage: `Attempt failed, retrying: ${message}` }) + .catch(() => undefined); + throw cause; + } + await store.settle(taskId, { + status: "failed", + statusMessage: "Execution failed", + error: { code: ProtocolErrorCode.InternalError, message }, + }); + throw cause; + } + } + + async function runInline(name: string, args: unknown): Promise> { + const handler = handlers.get(name); + if (!handler) throw new Error(`No task handler registered for "${name}".`); + return await (handler as TaskHandler)(args, { + taskId: "", + update: async () => undefined, + isCancelled: async () => false, + }); + } + + async function required(taskId: string): Promise { + let task: Task | null; + try { + task = await store.get(taskId); + } catch (cause) { + if (cause instanceof UnknownTaskError) task = null; + else throw cause; + } + // An expired task is indistinguishable from one that never existed, which is the right + // answer to give: the draft lets a server discard a task once its TTL elapses. + if (!task) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown task: ${taskId}`); + return task; + } + + return { registerTask, executeTask, getTask: (taskId) => store.get(taskId), store, dispatcher }; +} + +/** + * The answer to a task tool called by a client that cannot handle a task handle. + * + * It is a tool *error result*, not a thrown error, because `McpServer` catches everything a tool + * callback throws — `ProtocolError` included — and flattens it into `{ content, isError: true }`, + * dropping the code on the way. Verified against the SDK: throwing + * `MissingRequiredClientCapabilityError` reaches the client as an `isError` result whose `-32021` + * is nowhere to be found. So the code and the capability the caller is missing are put in + * `structuredContent`, where a client can actually read them. + */ +function missingCapabilityResult(toolName: string): Record { + return { + isError: true, + content: [ + { + type: "text", + text: + `"${toolName}" answers with a task handle, which requires the ` + + `"${TASKS_EXTENSION}" client capability. Declare it in the request's ` + + `_meta["${CLIENT_CAPABILITIES_META_KEY}"].extensions and call again.`, + }, + ], + structuredContent: { + code: ProtocolErrorCode.MissingRequiredClientCapability, + requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } }, + }, + }; +} + +/** + * Reads the tasks capability off the per-request envelope. + * + * Statelessness is why this is not a session lookup: there is no initialize handshake to remember + * what the client supports, so every request carries its own capabilities in `_meta` and the SDK + * lifts them onto `ctx.mcpReq.envelope`. + */ +function clientSupportsTasks(context: unknown): boolean { + const envelope = (context as { mcpReq?: { envelope?: Record } } | undefined) + ?.mcpReq?.envelope; + const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] as + | { extensions?: Record } + | undefined; + return Boolean(capabilities?.extensions && TASKS_EXTENSION in capabilities.extensions); +} + +/** Strips the server-only fields, leaving exactly what the draft schema puts on the wire. */ +export function toWire(task: Task): WireTask { + const { name: _name, args: _args, dispatchId: _dispatchId, ...wire } = task; + return wire; +} diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-tasks/src/index.ts new file mode 100644 index 0000000..99abc49 --- /dev/null +++ b/packages/mcp-tasks/src/index.ts @@ -0,0 +1,40 @@ +/** + * `@upstash/mcp-tasks` — a durable MCP Tasks runtime for the official TypeScript SDK. + * + * The core here is storage-agnostic. The Upstash Redis + QStash backends live behind the + * `@upstash/mcp-tasks/upstash` entry point, so bringing your own store costs you nothing. + */ +export { + createTaskLayer, + toWire, + TASK_METHODS, + TASKS_EXTENSION, + TASKS_PROTOCOL_VERSION, + type ExecuteTaskOptions, + type MissingCapabilityBehavior, + type TaskHandler, + type TaskLayer, + type TaskLayerOptions, + type TaskToolConfig, +} from "./core.js"; + +export { + isTerminal, + TERMINAL_STATUSES, + UnknownTaskError, + type Task, + type TaskContext, + type TaskDispatcher, + type TaskError, + type TaskPatch, + type TaskStatus, + type TaskStore, + type TerminalTaskPatch, + type TerminalTaskStatus, + type WireTask, +} from "./types.js"; + +export { InlineTaskDispatcher, MemoryTaskStore } from "./memory.js"; + +export { SDK_TELEMETRY } from "./telemetry.js"; +export { VERSION } from "./version.js"; diff --git a/packages/mcp-tasks/src/memory.ts b/packages/mcp-tasks/src/memory.ts new file mode 100644 index 0000000..2f07f99 --- /dev/null +++ b/packages/mcp-tasks/src/memory.ts @@ -0,0 +1,105 @@ +/** + * Single-process backends, for tests and for a first local run before you have QStash creds. + * + * They are honest about what they are: {@link MemoryTaskStore} loses everything on restart, and + * {@link InlineTaskDispatcher} runs the work in the process that accepted the tool call — the + * exact fire-and-forget shape that leaves a durable record of a task stuck in `working` when the + * process dies. Use them to develop against; use the Upstash backends to survive a deploy. + */ +import { + isTerminal, + UnknownTaskError, + type Task, + type TaskDispatcher, + type TaskPatch, + type TaskStore, + type TerminalTaskPatch, +} from "./types.js"; + +/** An in-process {@link TaskStore}. Not durable, not shared between instances. */ +export class MemoryTaskStore implements TaskStore { + private readonly tasks = new Map(); + private readonly timers = new Map>(); + + async create(task: Task): Promise { + this.tasks.set(task.taskId, { ...task }); + if (task.ttlMs !== null && task.ttlMs > 0) { + // Stands in for Redis EXPIRE. Unref'd so a pending TTL never holds the process open. + const timer = setTimeout(() => { + this.tasks.delete(task.taskId); + this.timers.delete(task.taskId); + }, task.ttlMs); + timer.unref?.(); + this.timers.set(task.taskId, timer); + } + } + + async get(taskId: string): Promise { + const task = this.tasks.get(taskId); + return task ? { ...task } : null; + } + + async update(taskId: string, patch: TaskPatch): Promise { + const task = this.tasks.get(taskId); + if (!task) throw new UnknownTaskError(taskId); + const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; + this.tasks.set(taskId, next); + return { ...next }; + } + + async settle(taskId: string, patch: TerminalTaskPatch): Promise { + const task = this.tasks.get(taskId); + // A single-threaded runtime gives this the atomicity the Lua script buys on Redis: nothing + // can interleave between the read and the write below. + if (!task || isTerminal(task.status)) return null; + const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; + this.tasks.set(taskId, next); + return { ...next }; + } + + /** Drops every task and its pending expiry. Handy between tests. */ + clear(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.tasks.clear(); + } +} + +/** + * Runs a task in the current process, on the next tick. + * + * There is nothing durable about it, and nothing to cancel once the work has started — `cancel` + * is a no-op, so stopping relies entirely on the handler checking `isCancelled()`. + */ +export class InlineTaskDispatcher implements TaskDispatcher { + private readonly pending = new Set>(); + + /** How many tasks have been dispatched. Test-only. */ + dispatched = 0; + + constructor(private readonly execute: (taskId: string) => Promise) {} + + async dispatch(taskId: string): Promise { + this.dispatched += 1; + // Deferred to a microtask so the tool call returns its handle before the work starts, which + // is the ordering a real queue gives you for free. + const run = Promise.resolve() + .then(() => this.execute(taskId)) + .then( + () => undefined, + () => undefined, // executeTask already recorded the failure on the task + ); + this.pending.add(run); + void run.finally(() => this.pending.delete(run)); + return undefined; + } + + async cancel(): Promise { + // Nothing to un-enqueue: the work is already running in this process. + } + + /** Resolves once every dispatched task has settled. Test-only. */ + async drain(): Promise { + while (this.pending.size > 0) await Promise.all([...this.pending]); + } +} diff --git a/packages/mcp-tasks/src/telemetry.ts b/packages/mcp-tasks/src/telemetry.ts new file mode 100644 index 0000000..f0cf8c8 --- /dev/null +++ b/packages/mcp-tasks/src/telemetry.ts @@ -0,0 +1,54 @@ +import { VERSION } from "./version.js"; + +/** + * Minimal shape of the redis client we need for telemetry. `addTelemetry` is `protected` in + * `@upstash/redis`, so it is not part of the public types. + */ +type TelemetryCapableRedis = { + addTelemetry?: (telemetry: { sdk?: string; platform?: string; runtime?: string }) => void; +}; + +/** The telemetry tag of this package. */ +export const SDK_TELEMETRY = `@upstash/mcp-tasks@${VERSION}`; + +/** + * The redis client *appends* to the telemetry header on every `addTelemetry` call, so each client + * is tagged once per sdk name no matter how many stores are built on it. + */ +const taggedClients = new WeakMap>(); + +const getSafeEnv = (): Record => + typeof process === "object" && process && typeof process.env === "object" ? process.env : {}; + +/** + * Reports the sdk name and version to Upstash through the redis client's telemetry headers, + * producing a header like `@upstash/redis@1.38.0,@upstash/mcp-tasks@0.1.0`. + * + * Opt out with `enableTelemetry: false` on the store config, with the same option on the redis + * client itself, or with the `UPSTASH_DISABLE_TELEMETRY` env var. + */ +export const addTelemetry = ( + redis: unknown, + options: { + /** The sdk tag to report. Defaults to {@link SDK_TELEMETRY}. */ + sdk?: string; + /** Set `false` to skip reporting. Defaults to `true`. */ + enabled?: boolean; + } = {}, +): void => { + const { sdk = SDK_TELEMETRY, enabled = true } = options; + if (!enabled || getSafeEnv().UPSTASH_DISABLE_TELEMETRY) return; + if (!redis || typeof redis !== "object") return; + + let tags = taggedClients.get(redis); + if (!tags) taggedClients.set(redis, (tags = new Set())); + if (tags.has(sdk)) return; + tags.add(sdk); + + try { + // addTelemetry is intentionally hidden from the public types of @upstash/redis + (redis as TelemetryCapableRedis).addTelemetry?.({ sdk }); + } catch { + // telemetry must never break the client + } +}; diff --git a/packages/mcp-tasks/src/test-support.ts b/packages/mcp-tasks/src/test-support.ts new file mode 100644 index 0000000..705a4f8 --- /dev/null +++ b/packages/mcp-tasks/src/test-support.ts @@ -0,0 +1,41 @@ +/** + * Test-only helpers (never imported by `index.ts`). + * + * Per the project's testing policy the store is exercised against a real Upstash Redis rather + * than a mock. Credentials come from the repo-root `.env`; without them `hasRedisCreds` is false + * and those suites skip themselves so CI without secrets stays green. + */ +import { randomUUID } from "node:crypto"; +import { config } from "dotenv"; +import { Redis } from "@upstash/redis"; + +// Load repo-root .env (no-op if already loaded or absent). +config(); + +export const hasRedisCreds = Boolean( + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN, +); + +/** A real Upstash Redis client from env. Only call when `hasRedisCreds` is true. */ +export function testRedis(): Redis { + return Redis.fromEnv(); +} + +/** A collision-proof key prefix so parallel runs never share keys. */ +export function uniquePrefix(label: string): string { + return `test:mcp-tasks:${label}:${randomUUID().slice(0, 8)}:`; +} + +/** Delete every key under a key prefix (best-effort cleanup in afterAll hooks). */ +export async function cleanupKeys(redis: Redis, prefix: string): Promise { + let cursor = "0"; + do { + const [next, keys] = await redis.scan(cursor, { match: `${prefix}*`, count: 200 }); + cursor = next; + if (keys.length) await redis.del(...keys); + } while (cursor !== "0"); +} + +/** Resolves after `ms`. */ +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/mcp-tasks/src/types.ts b/packages/mcp-tasks/src/types.ts new file mode 100644 index 0000000..85e8fb5 --- /dev/null +++ b/packages/mcp-tasks/src/types.ts @@ -0,0 +1,159 @@ +/** + * The storage and execution seams of the tasks runtime. + * + * MCP Tasks says how a client and a server talk about long-running work; it says nothing about + * where that work runs. Those are two different durability problems, so they get two interfaces: + * a {@link TaskStore} owns the task *record*, a {@link TaskDispatcher} owns the *execution*. The + * core in `core.ts` depends only on these, so Upstash Redis + QStash (`upstash.ts`) are a swap, + * not a hard-coded backend. + */ + +/** + * The five states of the `io.modelcontextprotocol/tasks` extension. `completed`, `failed` and + * `cancelled` are terminal: once a task reaches one, its status never changes again. + */ +export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; + +/** The three terminal states, as a type. */ +export type TerminalTaskStatus = Extract; + +/** The terminal states, as a runtime set. */ +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", +]); + +/** True when a status is terminal and can never transition again. */ +export const isTerminal = (status: TaskStatus): status is TerminalTaskStatus => + TERMINAL_STATUSES.has(status); + +/** + * Thrown by a {@link TaskStore} when a task id does not resolve — unknown, or expired past its TTL. + * + * It is deliberately *not* an MCP `ProtocolError`: a store implementation should not have to + * import the MCP SDK to be a valid store. The core translates this into the protocol error the + * client sees. + */ +export class UnknownTaskError extends Error { + override readonly name = "UnknownTaskError"; + constructor(readonly taskId: string) { + super(`Unknown task: ${taskId}`); + } +} + +/** A JSON-RPC error, as carried by a `failed` task. */ +export type TaskError = { + code: number; + message: string; + data?: unknown; +}; + +/** + * Exactly the object a client sees, straight from the extension's draft schema. Everything the + * server keeps for itself lives on {@link Task} instead, and is stripped on the way out. + */ +export type WireTask = { + taskId: string; + status: TaskStatus; + statusMessage?: string; + /** ISO-8601. */ + createdAt: string; + /** ISO-8601. */ + lastUpdatedAt: string; + /** Retention window in milliseconds. `null` means unlimited. */ + ttlMs: number | null; + /** How long the client should wait between `tasks/get` polls. */ + pollIntervalMs?: number; + /** Present once the task is `completed`: the tool result, inline. */ + result?: Record; + /** Present once the task is `failed`. */ + error?: TaskError; +}; + +/** + * The stored task: the wire object plus the three fields the server needs and the client never + * sees — which tool to run, what to run it with, and which dispatch to cancel. + */ +export type Task = WireTask & { + /** The registered task name, so the executor knows which handler to run. */ + name: string; + /** The validated tool input, replayed into the handler on delivery. */ + args: unknown; + /** The dispatcher's handle for the pending delivery, so cancel can stop retries. */ + dispatchId?: string; +}; + +/** The fields a caller may patch on a stored task. */ +export type TaskPatch = Partial>; + +/** A patch that moves a task into a terminal state. */ +export type TerminalTaskPatch = TaskPatch & { status: TerminalTaskStatus }; + +/** + * Durable storage for the task record. + * + * The one hard requirement comes from the spec: a `tools/call` must not return the task handle + * until the task is durably created, because the client may immediately `tasks/get` it against a + * different instance. So {@link create} must have committed before it resolves. + */ +export interface TaskStore { + /** Durably persists a new task before resolving. */ + create(task: Task): Promise; + + /** Returns the latest durable state of a task, or `null` when it is absent or expired. */ + get(taskId: string): Promise; + + /** + * Applies a partial update without extending the task's original TTL — the retention window is + * measured from creation, so a chatty progress handler must not keep a task alive forever. + * + * Used for non-terminal writes (progress messages). Terminal transitions go through + * {@link settle} so they cannot race. + */ + update(taskId: string, patch: TaskPatch): Promise; + + /** + * Atomically moves a **non-terminal** task to a terminal state. Returns the settled task when + * this call performed the transition, or `null` when the task was already terminal. + * + * This is the one operation that must not be a read-modify-write, because two writers race for + * it by design: a client's `tasks/cancel` and the executor finishing at the same moment. First + * terminal write wins, and a late `completed` can never overwrite a `cancelled`. + */ + settle(taskId: string, patch: TerminalTaskPatch): Promise; +} + +/** + * Durable execution transport. + * + * A store keeps the record alive across a restart; only a dispatcher keeps the *work* alive. The + * contract is deliberately at-least-once — that is what a queue can actually promise — so the + * core guards against redelivery rather than assuming a message arrives exactly once. + */ +export interface TaskDispatcher { + /** + * Durably accepts an at-least-once delivery for a task before resolving, and returns a handle + * that {@link cancel} understands. Return `undefined` when the transport has nothing to cancel. + * + * Implementations should be idempotent in the task id: dispatching the same task twice must + * not enqueue two deliveries. + */ + dispatch(taskId: string): Promise; + + /** Idempotently stops a pending delivery and its future retries, when the transport can. */ + cancel(dispatchId: string): Promise; +} + +/** What a task handler is handed alongside its arguments. */ +export type TaskContext = { + /** The id of the running task. */ + taskId: string; + /** Publishes a human-readable progress line that the client's next poll will see. */ + update(statusMessage: string): Promise; + /** + * Reads the durable status to see whether a client asked to stop. Cancellation is cooperative: + * running code only stops where it checks, so call this at your step boundaries. + */ + isCancelled(): Promise; +}; diff --git a/packages/mcp-tasks/src/upstash.test.ts b/packages/mcp-tasks/src/upstash.test.ts new file mode 100644 index 0000000..19b0acb --- /dev/null +++ b/packages/mcp-tasks/src/upstash.test.ts @@ -0,0 +1,184 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { QStashDispatcher, RedisTaskStore, isFinalQStashAttempt } from "./upstash.js"; +import { UnknownTaskError, type Task } from "./types.js"; +import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js"; + +const makeTask = (overrides: Partial = {}): Task => { + const now = new Date().toISOString(); + return { + taskId: `task-${Math.random().toString(36).slice(2, 10)}`, + status: "working", + statusMessage: "Queued for durable execution", + createdAt: now, + lastUpdatedAt: now, + ttlMs: 300_000, + pollIntervalMs: 2_000, + name: "generate_report", + args: { topic: "coffee trends" }, + ...overrides, + }; +}; + +describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { + const redis = testRedis(); + const prefix = uniquePrefix("store"); + const store = new RedisTaskStore({ redis, prefix }); + + afterAll(async () => { + await cleanupKeys(redis, prefix); + }); + + it("round-trips a task exactly, including values that look like other JSON types", async () => { + // "123" and "true" are the trap: an unencoded write comes back as a number and a boolean, + // because @upstash/redis JSON-parses responses. + const task = makeTask({ + statusMessage: "123", + args: { topic: "true", nested: { count: 4 }, list: [1, 2, 3] }, + }); + await store.create(task); + + const loaded = await store.get(task.taskId); + expect(loaded).not.toBeNull(); + expect(loaded?.statusMessage).toBe("123"); + expect(typeof loaded?.statusMessage).toBe("string"); + expect(loaded?.args).toEqual({ topic: "true", nested: { count: 4 }, list: [1, 2, 3] }); + expect(loaded?.ttlMs).toBe(300_000); + expect(loaded?.status).toBe("working"); + expect(loaded?.name).toBe("generate_report"); + }); + + it("returns null for an unknown task", async () => { + expect(await store.get("definitely-not-a-task")).toBeNull(); + }); + + it("sets a TTL from ttlMs, and update does not extend it", async () => { + const task = makeTask({ ttlMs: 60_000 }); + await store.create(task); + + const initial = await redis.pttl(prefix + task.taskId); + expect(initial).toBeGreaterThan(0); + expect(initial).toBeLessThanOrEqual(60_000); + + await store.update(task.taskId, { statusMessage: "Step 1/4" }); + const afterUpdate = await redis.pttl(prefix + task.taskId); + // Still counting down from creation rather than reset — a chatty handler must not be able to + // keep a task alive past its retention window. + expect(afterUpdate).toBeLessThanOrEqual(initial); + expect(afterUpdate).toBeGreaterThan(0); + }); + + it("stores no TTL when ttlMs is null", async () => { + const task = makeTask({ ttlMs: null }); + await store.create(task); + expect(await redis.pttl(prefix + task.taskId)).toBe(-1); + expect((await store.get(task.taskId))?.ttlMs).toBeNull(); + }); + + it("patches only the fields it is given", async () => { + const task = makeTask(); + await store.create(task); + + const updated = await store.update(task.taskId, { statusMessage: "Step 2/4" }); + expect(updated.statusMessage).toBe("Step 2/4"); + expect(updated.status).toBe("working"); + expect(updated.args).toEqual({ topic: "coffee trends" }); + expect(updated.lastUpdatedAt >= task.lastUpdatedAt).toBe(true); + }); + + it("throws UnknownTaskError when updating a task that is gone", async () => { + await expect(store.update("missing-task", { statusMessage: "x" })).rejects.toBeInstanceOf( + UnknownTaskError, + ); + }); + + it("settles a working task and refuses every settle after it", async () => { + const task = makeTask(); + await store.create(task); + + const completed = await store.settle(task.taskId, { + status: "completed", + statusMessage: "Completed", + result: { content: [{ type: "text", text: "done" }] }, + }); + expect(completed?.status).toBe("completed"); + expect(completed?.result).toEqual({ content: [{ type: "text", text: "done" }] }); + + // First terminal write wins: a later cancel cannot reopen or overwrite it. + const cancelled = await store.settle(task.taskId, { status: "cancelled" }); + expect(cancelled).toBeNull(); + expect((await store.get(task.taskId))?.status).toBe("completed"); + }); + + it("loses the completion race to a cancel that got there first", async () => { + const task = makeTask(); + await store.create(task); + + expect((await store.settle(task.taskId, { status: "cancelled" }))?.status).toBe("cancelled"); + // This is the executor finishing just after the client cancelled. + expect(await store.settle(task.taskId, { status: "completed", result: {} })).toBeNull(); + expect((await store.get(task.taskId))?.status).toBe("cancelled"); + }); + + it("returns null when settling a task that does not exist", async () => { + expect(await store.settle("missing-task", { status: "completed" })).toBeNull(); + }); + + it("never creates a task as a side effect of updating a missing one", async () => { + await expect(store.update("ghost", { statusMessage: "x" })).rejects.toBeInstanceOf( + UnknownTaskError, + ); + expect(await redis.exists(prefix + "ghost")).toBe(0); + }); +}); + +describe("constructing without credentials", () => { + // A store and a dispatcher are normally created at module scope, and a Next.js production build + // imports every route module to collect page data — with no environment loaded. Throwing in the + // constructor fails the build of an app that would run fine in production, so the clients are + // resolved on first use instead. + const saved = { ...process.env }; + beforeEach(() => { + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + delete process.env.QSTASH_TOKEN; + }); + afterEach(() => { + process.env = { ...saved }; + }); + + it("builds a RedisTaskStore with no env set", () => { + expect(() => new RedisTaskStore()).not.toThrow(); + }); + + it("builds a QStashDispatcher with no env set", () => { + expect(() => new QStashDispatcher({ url: "https://example.com/api/execute" })).not.toThrow(); + }); + + it("still reports the missing credentials when the client is actually used", async () => { + await expect(new RedisTaskStore().get("t")).rejects.toThrow(/UPSTASH_REDIS_REST_URL/); + await expect( + new QStashDispatcher({ url: "https://example.com/api/execute" }).dispatch("t"), + ).rejects.toThrow(/QSTASH_TOKEN/); + }); +}); + +describe("isFinalQStashAttempt", () => { + it("is false while retries remain", () => { + expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "0" }), 3)).toBe(false); + expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "2" }), 3)).toBe(false); + }); + + it("is true on the last attempt", () => { + expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "3" }), 3)).toBe(true); + expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "9" }), 3)).toBe(true); + }); + + it("treats a non-QStash delivery as final, so a failure is still recorded", () => { + expect(isFinalQStashAttempt(new Headers(), 3)).toBe(true); + }); + + it("reads a plain header record too", () => { + expect(isFinalQStashAttempt({ "Upstash-Retried": "1" }, 5)).toBe(false); + expect(isFinalQStashAttempt({ "Upstash-Retried": "5" }, 5)).toBe(true); + }); +}); diff --git a/packages/mcp-tasks/src/upstash.ts b/packages/mcp-tasks/src/upstash.ts new file mode 100644 index 0000000..03ea93a --- /dev/null +++ b/packages/mcp-tasks/src/upstash.ts @@ -0,0 +1,325 @@ +/** + * The Upstash backends: a {@link TaskStore} on Upstash Redis and a {@link TaskDispatcher} on + * QStash. + * + * Nothing here imports the MCP SDK. That is the point of the two interfaces — a Postgres store or + * a BullMQ dispatcher drops in without the core noticing. + */ +import { Redis } from "@upstash/redis"; +import { Client as QStashClient } from "@upstash/qstash"; +import { + TERMINAL_STATUSES, + UnknownTaskError, + type Task, + type TaskDispatcher, + type TaskPatch, + type TaskStore, + type TerminalTaskPatch, +} from "./types.js"; +import { addTelemetry } from "./telemetry.js"; + +/** Default key prefix for task hashes: `mcp:task:`. */ +export const DEFAULT_TASK_PREFIX = "mcp:task:"; + +/** Exponential backoff — 1s, 2s, 4s, 8s, 16s across the default five retries. */ +export const DEFAULT_RETRY_DELAY = "pow(2, retried) * 1000"; + +export type RedisTaskStoreConfig = { + /** The Upstash Redis client. Defaults to `Redis.fromEnv()`. */ + redis?: Redis; + /** Key prefix for task hashes. Defaults to {@link DEFAULT_TASK_PREFIX}. */ + prefix?: string; + /** Set `false` to skip reporting the SDK version in the Redis telemetry header. */ + enableTelemetry?: boolean; +}; + +/** + * Every field is written JSON-encoded, and read back with no decoding of our own. + * + * That pairing is deliberate. `@upstash/redis` deserializes responses by default: it runs one + * `JSON.parse` over each value and falls back to the raw string. Writing `JSON.stringify(value)` + * makes that single parse the exact inverse of the write, so a status message of `"123"` returns + * as the string `"123"` and not the number `123` — which is what an unencoded write, or a second + * decode of our own, would produce. + */ +const encode = (value: unknown): string => JSON.stringify(value ?? null); + +/** + * The fields whose values are objects. They are the only ones worth repairing if a caller + * supplied a client built with `automaticDeserialization: false`, since a half-decoded scalar is + * indistinguishable from a legitimate string. + */ +const OBJECT_FIELDS: ReadonlySet = new Set(["args", "result", "error"]); + +/** + * Moves a task to a terminal state only if it is not terminal already, in one round trip. + * + * `ARGV[1]` is how many terminal-status literals follow; the rest are field/value pairs. Returns + * 1 when this call performed the transition, 0 when the task was missing or already terminal. + */ +const SETTLE_SCRIPT = ` +local current = redis.call('HGET', KEYS[1], 'status') +if not current then return 0 end +local terminals = tonumber(ARGV[1]) +for i = 2, 1 + terminals do + if current == ARGV[i] then return 0 end +end +for i = 2 + terminals, #ARGV, 2 do + redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1]) +end +return 1 +`; + +const TERMINAL_LITERALS = [...TERMINAL_STATUSES].map(encode); + +/** + * A task record per Redis hash, with `EXPIRE` doing the TTL cleanup the draft asks for. + * + * One field per task property, rather than one JSON blob, so an update is a plain `HSET` of just + * the fields that changed. Two writers — a progress update and a client's cancel — therefore + * cannot clobber each other's fields, which a read-modify-write of a single blob would. + */ +export class RedisTaskStore implements TaskStore { + private readonly prefix: string; + private readonly enableTelemetry: boolean; + private readonly resolveRedis: () => Redis; + private client: Redis | undefined; + + constructor(config: RedisTaskStoreConfig | Redis = {}) { + // Accept a bare client too, so `new RedisTaskStore(redis)` reads naturally. + const options: RedisTaskStoreConfig = isRedisClient(config) ? { redis: config } : config; + this.prefix = options.prefix ?? DEFAULT_TASK_PREFIX; + this.enableTelemetry = options.enableTelemetry ?? true; + this.resolveRedis = () => options.redis ?? redisFromEnv(); + } + + /** + * The client, resolved on first use rather than in the constructor. + * + * Constructing must not need credentials: a store is typically created at module scope, and a + * framework evaluates those modules in places where the environment is not populated — a Next.js + * production build imports every route module to collect page data, so an eager `fromEnv()` fails + * the build of an app that would run fine in production. + */ + private get redis(): Redis { + if (!this.client) { + this.client = this.resolveRedis(); + addTelemetry(this.client, { enabled: this.enableTelemetry }); + } + return this.client; + } + + async create(task: Task): Promise { + const key = this.key(task.taskId); + await this.redis.hset(key, toFields(task)); + // The TTL is set once, at creation. Later updates use HSET, which never touches it, so the + // retention window is measured from creation no matter how chatty the handler is. + if (task.ttlMs !== null && task.ttlMs > 0) { + await this.redis.pexpire(key, task.ttlMs); + } + } + + async get(taskId: string): Promise { + const fields = await this.redis.hgetall>(this.key(taskId)); + if (!fields || Object.keys(fields).length === 0) return null; + return fromFields(fields); + } + + async update(taskId: string, patch: TaskPatch): Promise { + const fields = toFields({ ...patch, lastUpdatedAt: new Date().toISOString() }); + // HSET on a missing key would create a partial, TTL-less task, so check first. The check is + // not a lock: only `settle` needs atomicity, and it has it. + const exists = await this.redis.exists(this.key(taskId)); + if (!exists) throw new UnknownTaskError(taskId); + await this.redis.hset(this.key(taskId), fields); + const task = await this.get(taskId); + if (!task) throw new UnknownTaskError(taskId); + return task; + } + + async settle(taskId: string, patch: TerminalTaskPatch): Promise { + const fields = toFields({ ...patch, lastUpdatedAt: new Date().toISOString() }); + const args: string[] = [String(TERMINAL_LITERALS.length), ...TERMINAL_LITERALS]; + for (const [field, value] of Object.entries(fields)) args.push(field, value); + + const applied = await this.redis.eval( + SETTLE_SCRIPT, + [this.key(taskId)], + args, + ); + if (applied !== 1) return null; + return await this.get(taskId); + } + + /** The Redis key a task is stored under. */ + key(taskId: string): string { + return this.prefix + taskId; + } +} + +export type QStashDispatcherConfig = { + /** The QStash client. Defaults to `new Client({ token: QSTASH_TOKEN, baseUrl: QSTASH_URL })`. */ + qstash?: QStashClient; + /** + * The absolute, publicly reachable URL QStash delivers a task to. Your handler there reads + * `{ taskId }` from the body and calls `executeTask(taskId)`. + */ + url: string; + /** Delivery attempts before QStash gives up. Defaults to 5. */ + retries?: number; + /** + * Backoff between attempts, as a QStash delay expression. Defaults to exponential — + * `"pow(2, retried) * 1000"`, so 1s, 2s, 4s, 8s, 16s. + * + * The retry budget is what has to outlast a restart, and it is easy to get wrong: a flat + * `"1000"` with the default 5 retries burns every attempt within about five seconds, so a + * process killed mid-task exhausts its redeliveries before it is back up and the task is + * dead-lettered while still reading `working`. Exponential backoff spans ~31s instead. Size it + * against how long your deploys actually take. + */ + retryDelay?: string; + /** Extra headers to send with the delivery. */ + headers?: Record; +}; + +/** + * Publishes each task to QStash, which stores the message durably before delivery and retries a + * failing endpoint. That is the half Redis cannot do: if the process that accepted the tool call + * dies mid-run, the record survives in Redis but only a redelivery finishes the work. + */ +export class QStashDispatcher implements TaskDispatcher { + private readonly url: string; + /** How many retries this dispatcher asks QStash for. Pair it with {@link isFinalQStashAttempt}. */ + readonly retries: number; + private readonly retryDelay: string; + private readonly headers: Record | undefined; + private readonly resolveQStash: () => QStashClient; + private client: QStashClient | undefined; + + constructor(config: QStashDispatcherConfig) { + this.url = config.url; + this.retries = config.retries ?? 5; + this.retryDelay = config.retryDelay ?? DEFAULT_RETRY_DELAY; + this.headers = config.headers; + this.resolveQStash = () => config.qstash ?? qstashFromEnv(); + } + + /** Resolved on first use, for the same reason as {@link RedisTaskStore}'s client. */ + private get qstash(): QStashClient { + if (!this.client) this.client = this.resolveQStash(); + return this.client; + } + + async dispatch(taskId: string): Promise { + const message = await this.qstash.publishJSON({ + url: this.url, + body: { taskId }, + retries: this.retries, + retryDelay: this.retryDelay, + headers: this.headers, + // QStash delivery is at-least-once. Pinning deduplication to the task id means a + // double-submitted tool call cannot enqueue the same task twice. + deduplicationId: taskId, + }); + return Array.isArray(message) ? message[0]?.messageId : message.messageId; + } + + async cancel(dispatchId: string): Promise { + await this.qstash.messages.cancel(dispatchId); + } +} + +/** QStash's per-delivery header: how often this message has been retried so far, starting at 0. */ +export const QSTASH_RETRIED_HEADER = "upstash-retried"; + +/** + * Whether the delivery being handled is QStash's last attempt at this task. + * + * Pass the result to `executeTask` as `isFinalAttempt`. It is what keeps a transient failure + * retryable: before the last attempt the task stays `working` so a retry can still finish it, and + * only the last one settles it `failed`. + * + * @param headers the incoming request's headers + * @param maxRetries the retry count the dispatcher was configured with (`dispatcher.retries`) + */ +export function isFinalQStashAttempt( + headers: Headers | Record, + maxRetries: number, +): boolean { + const raw = + typeof (headers as Headers).get === "function" + ? (headers as Headers).get(QSTASH_RETRIED_HEADER) + : firstHeader(headers as Record); + // No header means this is not a QStash delivery at all (a manual replay, say). Treating that as + // the final attempt keeps the safe default: the failure is recorded rather than left hanging. + // Note `Number(null)` and `Number("")` are both 0, so the emptiness check has to come first. + if (raw === null || raw === undefined || raw === "") return true; + const retried = Number(raw); + if (!Number.isFinite(retried)) return true; + return retried >= maxRetries; +} + +function firstHeader(headers: Record): string | undefined { + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() !== QSTASH_RETRIED_HEADER) continue; + return Array.isArray(value) ? value[0] : value; + } + return undefined; +} + +/** Encodes a partial task into the hash fields that represent it. `undefined` values are skipped. */ +function toFields(patch: Partial): Record { + const fields: Record = {}; + for (const [field, value] of Object.entries(patch)) { + if (value === undefined) continue; + fields[field] = encode(value); + } + return fields; +} + +/** + * Turns stored hash fields back into a task. The client's own deserialization has already undone + * {@link encode}, so this only skips absent fields and repairs objects that arrived as strings. + */ +function fromFields(fields: Record): Task { + const task: Record = {}; + for (const [field, raw] of Object.entries(fields)) { + if (raw === undefined) continue; + if (typeof raw === "string" && OBJECT_FIELDS.has(field)) { + try { + task[field] = JSON.parse(raw); + continue; + } catch { + // Not JSON after all — fall through and keep the raw value. + } + } + // `ttlMs: null` is meaningful ("unlimited"), so nulls are kept rather than dropped. + task[field] = raw; + } + if (!("ttlMs" in task)) task.ttlMs = null; + return task as Task; +} + +function isRedisClient(value: RedisTaskStoreConfig | Redis): value is Redis { + return typeof (value as Redis).hgetall === "function"; +} + +function redisFromEnv(): Redis { + const { UPSTASH_REDIS_REST_URL: url, UPSTASH_REDIS_REST_TOKEN: token } = process.env; + if (!url || !token) { + throw new Error( + "RedisTaskStore needs a client: pass `redis`, or set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.", + ); + } + return new Redis({ url, token }); +} + +function qstashFromEnv(): QStashClient { + const token = process.env.QSTASH_TOKEN; + if (!token) { + throw new Error("QStashDispatcher needs a client: pass `qstash`, or set QSTASH_TOKEN."); + } + // QSTASH_URL points the client at the local dev server when one is running; the hosted URL is + // the client's own default. + return new QStashClient({ token, baseUrl: process.env.QSTASH_URL }); +} diff --git a/packages/mcp-tasks/src/version.ts b/packages/mcp-tasks/src/version.ts new file mode 100644 index 0000000..a05375c --- /dev/null +++ b/packages/mcp-tasks/src/version.ts @@ -0,0 +1,2 @@ +// Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. +export const VERSION = "0.1.0"; diff --git a/packages/mcp-tasks/tsconfig.json b/packages/mcp-tasks/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/mcp-tasks/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/mcp-tasks/tsup.config.ts b/packages/mcp-tasks/tsup.config.ts new file mode 100644 index 0000000..4d4a102 --- /dev/null +++ b/packages/mcp-tasks/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + upstash: "src/upstash.ts", + }, + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c35876..6d8a0e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,46 @@ importers: specifier: 7.0.2 version: 7.0.2 + examples/mcp-tasks-demo: + dependencies: + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 + '@upstash/mcp-tasks': + specifier: workspace:* + version: link:../../packages/mcp-tasks + '@upstash/qstash': + specifier: ^2.11.3 + version: 2.11.3 + '@upstash/redis': + specifier: ^1.38.0 + version: 1.38.0 + next: + specifier: 16.2.9 + version: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.43 + '@types/react': + specifier: 19.2.15 + version: 19.2.15 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.15) + typescript: + specifier: ^5 + version: 5.9.3 + packages/ai-sdk: dependencies: '@upstash/agentkit-sdk': @@ -309,6 +349,28 @@ importers: specifier: 7.0.2 version: 7.0.2 + packages/mcp-tasks: + dependencies: + zod: + specifier: ^4.2.0 + version: 4.4.3 + devDependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 + '@upstash/qstash': + specifier: ^2.11.3 + version: 2.11.3 + '@upstash/redis': + specifier: ^1.38.0 + version: 1.38.0 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + packages/sdk: dependencies: '@upstash/ratelimit': @@ -987,6 +1049,18 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -2773,6 +2847,9 @@ packages: resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} engines: {node: '>=16.0.0'} + '@upstash/qstash@2.11.3': + resolution: {integrity: sha512-d5saGTDlkbKDFSANENtyf+zqkJen9ho17S24x9lXo+DwWjZ9IDM5WKUzUsshUv8ufZsSpxJWyrZ8jZ6r8dBuhg==} + '@upstash/ratelimit@2.0.8': resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==} peerDependencies: @@ -3048,6 +3125,10 @@ packages: srvx: optional: true + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3421,6 +3502,10 @@ packages: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3730,6 +3815,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4159,6 +4247,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neverthrow@7.2.0: + resolution: {integrity: sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==} + engines: {node: '>=18'} + next@16.2.6: resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} @@ -5712,6 +5804,25 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.10 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -7340,6 +7451,12 @@ snapshots: dependencies: '@upstash/redis': 1.38.0 + '@upstash/qstash@2.11.3': + dependencies: + crypto-js: 4.2.0 + jose: 5.10.0 + neverthrow: 7.2.0 + '@upstash/ratelimit@2.0.8(@upstash/redis@1.38.0)': dependencies: '@upstash/core-analytics': 0.0.10 @@ -7590,6 +7707,8 @@ snapshots: optionalDependencies: srvx: 0.11.16 + crypto-js@4.2.0: {} + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): @@ -8082,6 +8201,10 @@ snapshots: eventsource-parser@3.1.1: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -8440,6 +8563,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.10: {} + joycon@3.1.1: {} js-yaml@3.14.2: @@ -9069,6 +9194,8 @@ snapshots: natural-compare@1.4.0: {} + neverthrow@7.2.0: {} + next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 16.2.6 diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs index 1136177..0929df3 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.mjs @@ -18,6 +18,7 @@ const TARGETS = { "packages/ai-sdk": "src/version.ts", "packages/eve": "src/version.ts", "packages/eve-extension": "extension/lib/version.ts", + "packages/mcp-tasks": "src/version.ts", }; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); From d58d3517fd72e4a38f80ef8ef0ab615618f56a7b Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 1 Sep 2026 14:56:57 +0300 Subject: [PATCH 02/11] feat(mcp-tasks): let the dispatcher own its delivery endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/hot-jars-judge.md | 19 +++ CLAUDE.md | 28 ++++- examples/mcp-tasks-demo/README.md | 9 +- .../mcp-tasks-demo/app/api/execute/route.ts | 48 ++----- examples/mcp-tasks-demo/app/lib/tasks.ts | 4 +- packages/mcp-tasks/README.md | 46 +++---- packages/mcp-tasks/src/core.ts | 34 ++++- packages/mcp-tasks/src/index.ts | 1 + packages/mcp-tasks/src/types.ts | 19 +++ packages/mcp-tasks/src/upstash.test.ts | 105 ++++++++++++++++ packages/mcp-tasks/src/upstash.ts | 118 ++++++++++++++++-- 11 files changed, 348 insertions(+), 83 deletions(-) create mode 100644 .changeset/hot-jars-judge.md diff --git a/.changeset/hot-jars-judge.md b/.changeset/hot-jars-judge.md new file mode 100644 index 0000000..3fac551 --- /dev/null +++ b/.changeset/hot-jars-judge.md @@ -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`. Modelled on Vercel Workflow's `Queue.createQueueHandler`. + +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`. diff --git a/CLAUDE.md b/CLAUDE.md index 2cf65d5..527a961 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -353,10 +353,30 @@ Verified empirically against `@modelcontextprotocol/server@2.0.0`; don't re-deri 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. The default `retryDelay` is **exponential** (`"pow(2, retried) * 1000"`): - with a flat `"1000"` 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`). + 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):** among *official* MCP SDKs, only **C#** ships a pluggable + task store (`IMcpTaskStore`, 7 methods); 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. **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. Only unofficial FastMCP splits + execution durably (Docket queue + out-of-process workers), and it has no store seam because Docket + is both. So this package's `TaskStore` + `TaskDispatcher` split is not a port of prior 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 diff --git a/examples/mcp-tasks-demo/README.md b/examples/mcp-tasks-demo/README.md index 2d3e30a..6f04020 100644 --- a/examples/mcp-tasks-demo/README.md +++ b/examples/mcp-tasks-demo/README.md @@ -14,7 +14,7 @@ tasks, so you can watch the protocol rather than just the result. | --- | --- | | `app/lib/tasks.ts` | The whole server wiring: `RedisTaskStore`, `QStashDispatcher`, and the `generate_report` task tool | | `app/api/mcp/route.ts` | The MCP endpoint, over `WebStandardStreamableHTTPServerTransport` | -| `app/api/execute/route.ts` | Where QStash delivers a task — verifies the signature, then runs it | +| `app/api/execute/route.ts` | Where QStash delivers a task. One line: the dispatcher owns the endpoint | | `app/page.tsx` | The client: call the tool, poll, cancel, and the wire log | | `scripts/smoke.mjs` | Drives the same flow from the terminal and asserts on it | @@ -70,9 +70,10 @@ fire-and-forget promise and the same test leaves a permanently `working` task in One caveat this demo learned the hard way: the retry budget has to outlast your restart. QStash retries on its configured schedule and dead-letters the message when they run out, so with a flat one-second delay every attempt is spent within a few seconds — long before a dev server is back up, -leaving a task that reads `working` forever. The dispatcher defaults to exponential backoff -(1s, 2s, 4s, 8s, 16s) for that reason. If a task ends up dead-lettered anyway, it is in the QStash -DLQ, not lost. +leaving a task that reads `working` forever. The dispatcher's defaults spread five attempts over +about two minutes (1s, 3s, 9s, 27s, 81s) for that reason; five is also the ceiling the local dev +server and the free tier allow, so raising `retries` needs a plan that permits it. If a task does +get dead-lettered, it is in the QStash DLQ, not lost. ## Notes diff --git a/examples/mcp-tasks-demo/app/api/execute/route.ts b/examples/mcp-tasks-demo/app/api/execute/route.ts index 38d7da1..1a20c27 100644 --- a/examples/mcp-tasks-demo/app/api/execute/route.ts +++ b/examples/mcp-tasks-demo/app/api/execute/route.ts @@ -4,50 +4,16 @@ * This is where the work actually runs — in a different request, and possibly a different process, * from the `tools/call` that created the task. That separation is the whole point: the process * that accepted the call can die without taking the work with it. + * + * The handler comes from the dispatcher rather than being written here, because everything it has + * to get right belongs to the transport: verifying the QStash signature, reading the task id, + * counting which attempt this is, and answering with the status code that decides whether QStash + * tries again. */ -import { Receiver } from "@upstash/qstash"; -import { isFinalQStashAttempt } from "@upstash/mcp-tasks/upstash"; -import { dispatcher, EXECUTE_URL, tasks } from "../../lib/tasks"; +import { tasks } from "../../lib/tasks"; export const dynamic = "force-dynamic"; // The demo tool sleeps for ~10s; give the platform room to let it finish. export const maxDuration = 60; -const receiver = new Receiver({ - currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY ?? "", - nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY ?? "", -}); - -export async function POST(request: Request): Promise { - const body = await request.text(); - - // Without this, anyone who can reach the route can run tasks. - try { - await receiver.verify({ - signature: request.headers.get("upstash-signature") ?? "", - body, - url: EXECUTE_URL, - }); - } catch (cause) { - console.error("[execute] signature verification failed", cause); - // 401 is deliberate: a bad signature is not something a retry can fix. - return new Response("invalid signature", { status: 401 }); - } - - const { taskId } = JSON.parse(body) as { taskId?: string }; - if (!taskId) return new Response("missing taskId", { status: 400 }); - - // Whether a thrown handler is fatal depends on whether QStash will try again. - const isFinalAttempt = isFinalQStashAttempt(request.headers, dispatcher.retries); - - try { - console.log(`[execute] task=${taskId} starting (final attempt: ${isFinalAttempt})`); - const task = await tasks.executeTask(taskId, { isFinalAttempt }); - console.log(`[execute] task=${taskId} -> ${task?.status ?? "gone"}`); - return new Response("ok"); - } catch (cause) { - console.error(`[execute] task=${taskId} failed`, cause); - // A non-2xx is how you ask QStash to retry. - return new Response("retry", { status: 500 }); - } -} +export const POST = tasks.createExecuteHandler(); diff --git a/examples/mcp-tasks-demo/app/lib/tasks.ts b/examples/mcp-tasks-demo/app/lib/tasks.ts index e2bf9f2..f0079b7 100644 --- a/examples/mcp-tasks-demo/app/lib/tasks.ts +++ b/examples/mcp-tasks-demo/app/lib/tasks.ts @@ -12,7 +12,9 @@ import * as z from "zod"; /** Where QStash delivers a task. It has to be reachable *from QStash*, not just from your browser. */ export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute`; -export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL, retries: 5 }); +// `retries` and `retryDelay` are left at their defaults — a generous budget with exponential +// backoff, so a task outlives a restart rather than dead-lettering while it still reads `working`. +export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL }); export const tasks = createTaskLayer({ // Both default to `fromEnv()`, so there is no client to thread through. diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 52354fa..65da207 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -47,7 +47,7 @@ const tasks = createTaskLayer({ store: new RedisTaskStore(), // optional: { redis, prefix, enableTelemetry } dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute`, // where QStash delivers the task - retries: 3, // optional: attempts before giving up + // retries / retryDelay default to a budget that outlives a restart — see below }), defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, // optional }); @@ -95,25 +95,18 @@ return transport.handleRequest(request); ``` ```ts -// POST /api/execute -import { Receiver } from "@upstash/qstash"; -import { isFinalQStashAttempt } from "@upstash/mcp-tasks/upstash"; - -const body = await request.text(); -await receiver.verify({ signature: request.headers.get("upstash-signature")!, body, url: EXECUTE_URL }); - -try { - await tasks.executeTask(JSON.parse(body).taskId, { - isFinalAttempt: isFinalQStashAttempt(request.headers, dispatcher.retries), - }); - return new Response("ok"); -} catch { - return new Response("retry", { status: 500 }); // non-2xx asks QStash to retry -} +// app/api/execute/route.ts +export const POST = tasks.createExecuteHandler(); ``` -The `Receiver` check is not optional in production: without it anyone who can reach the route can -run tasks. +That second one is deliberately not yours to write. Verifying the QStash signature, reading the +task id, counting which attempt this is and picking the status code that decides whether QStash +tries again are all facts about the transport, and the dispatcher already knows them — so it hands +you the endpoint instead of a checklist. Skipping the signature check would let anyone who can +reach the route run tasks; here you cannot skip it. + +If you would rather wire it yourself, the pieces are still exported — `executeTask`, +`isFinalQStashAttempt(headers, dispatcher.retries)` and `@upstash/qstash`'s `Receiver`. ## What the client sees @@ -133,7 +126,7 @@ three are terminal and never change again. ## Design notes -Four things here are deliberate, and three of them differ from the obvious implementation. +Five things here are deliberate, and most of them differ from the obvious implementation. **The record is written before the handle goes out.** The spec requires it: the client may `tasks/get` the id against another instance the moment it has it. So `registerTask` creates, then @@ -156,6 +149,15 @@ that from the `Upstash-Retried` header. **Redelivery is expected, not exceptional.** At-least-once is the strongest thing a queue promises, so `executeTask` returns early on an already-terminal task. +**The retry budget has to outlast a restart.** This is the one default most likely to bite you. A +task is only as durable as the number of redeliveries left when the process died — run out, and the +record survives in Redis while nothing ever finishes the work, leaving `working` until the TTL +expires. QStash caps `retries` per plan (the local dev server and the free tier reject anything +above **5** with `quota maxRetries exceeded`), so the budget is bought with backoff instead: the +default delay is `min(pow(3, retried) * 1000, 300000)` — 1s, 3s, 9s, 27s, 81s, about two minutes +across five attempts. Raise `retries` if your plan allows; for comparison, Vercel's QStash-backed +Workflow world defaults to 47. + ## Two gotchas in the official SDK Both verified against `@modelcontextprotocol/server@2.0.0`, and both are why this package exists in @@ -202,13 +204,13 @@ is exactly the failure this package is about. | Export | What it is | | --- | --- | -| `createTaskLayer(options)` | The runtime: `{ registerTask, executeTask, getTask, store, dispatcher }` | -| `TaskStore`, `TaskDispatcher`, `TaskContext` | The two seams and what a handler is handed | +| `createTaskLayer(options)` | The runtime: `{ registerTask, executeTask, createExecuteHandler, getTask, store, dispatcher }` | +| `TaskStore`, `TaskDispatcher`, `TaskContext`, `TaskRunner` | The two seams, what a handler is handed, and what a delivery endpoint calls | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset that goes on the wire | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `TASKS_EXTENSION`, `TASKS_PROTOCOL_VERSION`, `TASK_METHODS` | The extension id, `"2026-07-28"`, the method names | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | -| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `isFinalQStashAttempt` | +| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `isFinalQStashAttempt`, `DEFAULT_RETRIES`, `DEFAULT_RETRY_DELAY` | ## Not implemented diff --git a/packages/mcp-tasks/src/core.ts b/packages/mcp-tasks/src/core.ts index 7d538c0..9fc9e8e 100644 --- a/packages/mcp-tasks/src/core.ts +++ b/packages/mcp-tasks/src/core.ts @@ -151,6 +151,19 @@ export type TaskLayer = { ): void; /** Runs a dispatched task. Call this from the endpoint your dispatcher delivers to. */ executeTask(taskId: string, options?: ExecuteTaskOptions): Promise; + /** + * The delivery endpoint as a fetch handler, when the dispatcher provides one: + * + * ```ts + * // app/api/execute/route.ts + * export const POST = tasks.createExecuteHandler(); + * ``` + * + * The transport owns authentication, the attempt count and the retry status codes, so the + * application does not have to re-derive them — and cannot forget to verify a signature. + * Throws if the dispatcher runs work in-process and has no endpoint to serve. + */ + createExecuteHandler(): (request: Request) => Promise; /** Reads a task record server-side, bypassing the protocol. */ getTask(taskId: string): Promise; /** The store this layer was built on. */ @@ -356,7 +369,26 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { return task; } - return { registerTask, executeTask, getTask: (taskId) => store.get(taskId), store, dispatcher }; + function createExecuteHandler(): (request: Request) => Promise { + if (!dispatcher.createExecuteHandler) { + throw new Error( + "This dispatcher has no delivery endpoint to serve — it runs tasks in the current " + + "process. Use a transport-backed dispatcher (e.g. QStashDispatcher) to expose one.", + ); + } + return dispatcher.createExecuteHandler((taskId, { isFinalAttempt }) => + executeTask(taskId, { isFinalAttempt }), + ); + } + + return { + registerTask, + executeTask, + createExecuteHandler, + getTask: (taskId) => store.get(taskId), + store, + dispatcher, + }; } /** diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-tasks/src/index.ts index 99abc49..4ff1d2e 100644 --- a/packages/mcp-tasks/src/index.ts +++ b/packages/mcp-tasks/src/index.ts @@ -27,6 +27,7 @@ export { type TaskDispatcher, type TaskError, type TaskPatch, + type TaskRunner, type TaskStatus, type TaskStore, type TerminalTaskPatch, diff --git a/packages/mcp-tasks/src/types.ts b/packages/mcp-tasks/src/types.ts index 85e8fb5..1408664 100644 --- a/packages/mcp-tasks/src/types.ts +++ b/packages/mcp-tasks/src/types.ts @@ -143,8 +143,27 @@ export interface TaskDispatcher { /** Idempotently stops a pending delivery and its future retries, when the transport can. */ cancel(dispatchId: string): Promise; + + /** + * Optionally, the transport's own delivery endpoint. + * + * A dispatcher that delivers over HTTP knows things the application should not have to: how the + * request is authenticated, where the task id sits in the body, which attempt this is, and which + * status code means "retry me". Implementing this keeps all of that inside the transport, so the + * application's route is `export const POST = tasks.createExecuteHandler()` rather than a + * hand-written endpoint that has to remember to verify a signature. + * + * Dispatchers that run work in-process have nothing to serve and leave it undefined. + */ + createExecuteHandler?(run: TaskRunner): (request: Request) => Promise; } +/** + * What a delivery endpoint calls to run a task — `executeTask`, with the attempt's finality + * already worked out by the transport that knows how to count its own retries. + */ +export type TaskRunner = (taskId: string, options: { isFinalAttempt: boolean }) => Promise; + /** What a task handler is handed alongside its arguments. */ export type TaskContext = { /** The id of the running task. */ diff --git a/packages/mcp-tasks/src/upstash.test.ts b/packages/mcp-tasks/src/upstash.test.ts index 19b0acb..0c96cd3 100644 --- a/packages/mcp-tasks/src/upstash.test.ts +++ b/packages/mcp-tasks/src/upstash.test.ts @@ -162,6 +162,111 @@ describe("constructing without credentials", () => { }); }); +describe("QStashDispatcher.createExecuteHandler", () => { + /** A Receiver stand-in: the real one needs live signing keys, and we are testing our own gate. */ + const receiver = (accept: boolean) => + ({ + verify: async () => { + if (!accept) throw new Error("bad signature"); + return true; + }, + }) as unknown as ConstructorParameters[0]["receiver"]; + + const dispatcher = (accept = true, retries = 3) => + new QStashDispatcher({ + url: "https://example.com/api/execute", + retries, + receiver: receiver(accept), + }); + + const deliver = (body: unknown, headers: Record = {}) => + new Request("https://internal.example/api/execute", { + method: "POST", + headers: { "upstash-signature": "sig", ...headers }, + body: JSON.stringify(body), + }); + + it("runs the task and acknowledges with 200", async () => { + const ran: { taskId: string; isFinalAttempt: boolean }[] = []; + const handler = dispatcher().createExecuteHandler(async (taskId, options) => { + ran.push({ taskId, ...options }); + }); + + const response = await handler(deliver({ taskId: "t1" }, { "upstash-retried": "0" })); + expect(response.status).toBe(200); + expect(ran).toEqual([{ taskId: "t1", isFinalAttempt: false }]); + }); + + it("tells the runner when QStash is out of retries", async () => { + const seen: boolean[] = []; + const handler = dispatcher(true, 3).createExecuteHandler( + async (_taskId, { isFinalAttempt }) => { + seen.push(isFinalAttempt); + }, + ); + + await handler(deliver({ taskId: "t1" }, { "upstash-retried": "2" })); + await handler(deliver({ taskId: "t1" }, { "upstash-retried": "3" })); + expect(seen).toEqual([false, true]); + }); + + it("answers 500 so QStash retries when the task throws", async () => { + const handler = dispatcher().createExecuteHandler(async () => { + throw new Error("boom"); + }); + const response = await handler(deliver({ taskId: "t1" })); + expect(response.status).toBe(500); + }); + + it("rejects an unsigned delivery with 401 and never runs the task", async () => { + let ran = false; + const handler = dispatcher(false).createExecuteHandler(async () => { + ran = true; + }); + + const response = await handler(deliver({ taskId: "t1" })); + // 401 rather than 500 on purpose: a retry cannot fix a bad signature, and answering 500 would + // make QStash replay an unauthenticated request. + expect(response.status).toBe(401); + expect(ran).toBe(false); + }); + + it("rejects a body with no task id, without asking for a retry", async () => { + const handler = dispatcher().createExecuteHandler(async () => undefined); + expect((await handler(deliver({}))).status).toBe(400); + expect( + ( + await handler( + new Request("https://internal.example/api/execute", { + method: "POST", + headers: { "upstash-signature": "sig" }, + body: "not json", + }), + ) + ).status, + ).toBe(400); + }); + + it("verifies against the published URL, not the incoming one", async () => { + // Behind a proxy the incoming URL is internal, while QStash signed the public destination. + const urls: string[] = []; + const spy = { + verify: async ({ url }: { url: string }) => { + urls.push(url); + return true; + }, + } as unknown as ConstructorParameters[0]["receiver"]; + + const handler = new QStashDispatcher({ + url: "https://public.example.com/api/execute", + receiver: spy, + }).createExecuteHandler(async () => undefined); + + await handler(deliver({ taskId: "t1" })); + expect(urls).toEqual(["https://public.example.com/api/execute"]); + }); +}); + describe("isFinalQStashAttempt", () => { it("is false while retries remain", () => { expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "0" }), 3)).toBe(false); diff --git a/packages/mcp-tasks/src/upstash.ts b/packages/mcp-tasks/src/upstash.ts index 03ea93a..dbb66b6 100644 --- a/packages/mcp-tasks/src/upstash.ts +++ b/packages/mcp-tasks/src/upstash.ts @@ -6,13 +6,14 @@ * a BullMQ dispatcher drops in without the core noticing. */ import { Redis } from "@upstash/redis"; -import { Client as QStashClient } from "@upstash/qstash"; +import { Client as QStashClient, Receiver } from "@upstash/qstash"; import { TERMINAL_STATUSES, UnknownTaskError, type Task, type TaskDispatcher, type TaskPatch, + type TaskRunner, type TaskStore, type TerminalTaskPatch, } from "./types.js"; @@ -21,8 +22,27 @@ import { addTelemetry } from "./telemetry.js"; /** Default key prefix for task hashes: `mcp:task:`. */ export const DEFAULT_TASK_PREFIX = "mcp:task:"; -/** Exponential backoff — 1s, 2s, 4s, 8s, 16s across the default five retries. */ -export const DEFAULT_RETRY_DELAY = "pow(2, retried) * 1000"; +/** + * Backoff between delivery attempts: 1s, 3s, 9s, 27s, 81s — about two minutes across the default + * {@link DEFAULT_RETRIES} attempts, capped so a longer budget cannot drift into hours. + * + * The steep base is doing real work. What the retry budget has to outlast is whatever killed the + * process — a deploy, a crash loop, a cold start. When it does not, the record survives in Redis + * but nothing ever finishes the job, and the task sits at `working` until its TTL expires: exactly + * the failure durable execution exists to prevent. A flat one-second delay spends every attempt + * inside ten seconds, which no restart fits into. + */ +export const DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"; + +/** + * Delivery attempts before QStash dead-letters a task. + * + * **QStash caps this per plan** — the local dev server and the free tier reject anything above 5 + * with `quota maxRetries exceeded`, so 5 is the highest value that works everywhere and the budget + * is bought with {@link DEFAULT_RETRY_DELAY} instead. Raise it if your plan allows; Vercel's own + * QStash-backed Workflow world defaults to 47. + */ +export const DEFAULT_RETRIES = 5; export type RedisTaskStoreConfig = { /** The Upstash Redis client. Defaults to `Redis.fromEnv()`. */ @@ -165,21 +185,28 @@ export type QStashDispatcherConfig = { * `{ taskId }` from the body and calls `executeTask(taskId)`. */ url: string; - /** Delivery attempts before QStash gives up. Defaults to 5. */ + /** + * Delivery attempts before QStash gives up and dead-letters the message. Defaults to + * {@link DEFAULT_RETRIES}. + */ retries?: number; /** * Backoff between attempts, as a QStash delay expression. Defaults to exponential — - * `"pow(2, retried) * 1000"`, so 1s, 2s, 4s, 8s, 16s. + * {@link DEFAULT_RETRY_DELAY}. * * The retry budget is what has to outlast a restart, and it is easy to get wrong: a flat - * `"1000"` with the default 5 retries burns every attempt within about five seconds, so a - * process killed mid-task exhausts its redeliveries before it is back up and the task is - * dead-lettered while still reading `working`. Exponential backoff spans ~31s instead. Size it - * against how long your deploys actually take. + * `"1000"` with a handful of retries burns every attempt within seconds, so a process killed + * mid-task exhausts its redeliveries before it is back up and the task is dead-lettered while + * still reading `working`. Size the budget against how long your deploys actually take. */ retryDelay?: string; /** Extra headers to send with the delivery. */ headers?: Record; + /** + * Verifies the signature on incoming deliveries in {@link QStashDispatcher.createExecuteHandler}. + * Defaults to a `Receiver` built from `QSTASH_CURRENT_SIGNING_KEY` / `QSTASH_NEXT_SIGNING_KEY`. + */ + receiver?: Receiver; }; /** @@ -195,13 +222,16 @@ export class QStashDispatcher implements TaskDispatcher { private readonly headers: Record | undefined; private readonly resolveQStash: () => QStashClient; private client: QStashClient | undefined; + private readonly resolveReceiver: () => Receiver; + private verifier: Receiver | undefined; constructor(config: QStashDispatcherConfig) { this.url = config.url; - this.retries = config.retries ?? 5; + this.retries = config.retries ?? DEFAULT_RETRIES; this.retryDelay = config.retryDelay ?? DEFAULT_RETRY_DELAY; this.headers = config.headers; this.resolveQStash = () => config.qstash ?? qstashFromEnv(); + this.resolveReceiver = () => config.receiver ?? receiverFromEnv(); } /** Resolved on first use, for the same reason as {@link RedisTaskStore}'s client. */ @@ -210,6 +240,11 @@ export class QStashDispatcher implements TaskDispatcher { return this.client; } + private get receiver(): Receiver { + if (!this.verifier) this.verifier = this.resolveReceiver(); + return this.verifier; + } + async dispatch(taskId: string): Promise { const message = await this.qstash.publishJSON({ url: this.url, @@ -227,6 +262,58 @@ export class QStashDispatcher implements TaskDispatcher { async cancel(dispatchId: string): Promise { await this.qstash.messages.cancel(dispatchId); } + + /** + * The delivery endpoint, as a fetch handler: `export const POST = tasks.createExecuteHandler()`. + * + * It owns the four things the application would otherwise have to get right by hand — verifying + * the signature, reading the task id, counting the attempt, and choosing the status code that + * tells QStash whether to try again. + * + * Status codes are the retry contract: + * - **200** — the task ran, or was already terminal, or was redelivered after finishing. Done. + * - **401** — the signature did not verify. Deliberately terminal: a retry cannot fix a bad + * signature, and answering 500 would make QStash replay an unauthenticated request. + * - **400** — the body carried no task id. Also terminal, for the same reason. + * - **500** — the handler threw and QStash still has attempts left. This is the one that asks + * for a redelivery. + */ + createExecuteHandler(run: TaskRunner): (request: Request) => Promise { + return async (request: Request): Promise => { + const body = await request.text(); + + try { + // Verified against the URL we published to, not `request.url`: behind a proxy the + // incoming URL is the internal one, while QStash signed the public destination. + await this.receiver.verify({ + signature: request.headers.get("upstash-signature") ?? "", + body, + url: this.url, + }); + } catch { + return new Response("invalid signature", { status: 401 }); + } + + let taskId: string | undefined; + try { + taskId = (JSON.parse(body) as { taskId?: string }).taskId; + } catch { + return new Response("malformed body", { status: 400 }); + } + if (!taskId) return new Response("missing taskId", { status: 400 }); + + try { + await run(taskId, { + isFinalAttempt: isFinalQStashAttempt(request.headers, this.retries), + }); + return new Response("ok"); + } catch { + // The task's own failure is already recorded by `executeTask`; the non-2xx is purely how + // you ask QStash for another delivery. + return new Response("retry", { status: 500 }); + } + }; + } } /** QStash's per-delivery header: how often this message has been retried so far, starting at 0. */ @@ -314,6 +401,17 @@ function redisFromEnv(): Redis { return new Redis({ url, token }); } +function receiverFromEnv(): Receiver { + const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY; + const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY; + if (!currentSigningKey || !nextSigningKey) { + throw new Error( + "createExecuteHandler needs signing keys: pass `receiver`, or set QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY.", + ); + } + return new Receiver({ currentSigningKey, nextSigningKey }); +} + function qstashFromEnv(): QStashClient { const token = process.env.QSTASH_TOKEN; if (!token) { From 00bfecd280e754134267175a280edaf94131b0f0 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 1 Sep 2026 15:35:10 +0300 Subject: [PATCH 03/11] docs: separate 'implementable store seam' from 'Redis works today' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 527a961..1077e2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -369,14 +369,21 @@ Verified empirically against `@modelcontextprotocol/server@2.0.0`; don't re-deri 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):** among *official* MCP SDKs, only **C#** ships a pluggable - task store (`IMcpTaskStore`, 7 methods); 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. **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. Only unofficial FastMCP splits - execution durably (Docket queue + out-of-process workers), and it has no store seam because Docket - is both. So this package's `TaskStore` + `TaskDispatcher` split is not a port of prior art — - the closest analogue is Vercel Workflow's `World = Storage + Queue + Streamer`. +- **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 From e59b7e4eca56b7a427a26dc41f0b1631c5f6392e Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 16:14:34 +0300 Subject: [PATCH 04/11] feat(mcp-tasks): add a Workflow dispatcher, move retry logic to the transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/olive-pans-shave.md | 24 +++ examples/mcp-tasks-demo/.env.example | 5 + examples/mcp-tasks-demo/README.md | 18 ++- examples/mcp-tasks-demo/app/lib/tasks.ts | 42 ++++-- examples/mcp-tasks-demo/package.json | 3 +- packages/mcp-tasks/README.md | 61 ++++++-- packages/mcp-tasks/package.json | 13 +- packages/mcp-tasks/src/core.test.ts | 35 ++--- packages/mcp-tasks/src/core.ts | 81 ++++++----- packages/mcp-tasks/src/index.ts | 4 +- packages/mcp-tasks/src/memory.ts | 35 ++++- packages/mcp-tasks/src/types.ts | 66 ++++++++- packages/mcp-tasks/src/upstash.test.ts | 140 ++++++++++-------- packages/mcp-tasks/src/upstash.ts | 155 +++++++++++++------- packages/mcp-tasks/src/workflow.test.ts | 177 +++++++++++++++++++++++ packages/mcp-tasks/src/workflow.ts | 156 ++++++++++++++++++++ packages/mcp-tasks/tsup.config.ts | 1 + pnpm-lock.yaml | 16 ++ 18 files changed, 827 insertions(+), 205 deletions(-) create mode 100644 .changeset/olive-pans-shave.md create mode 100644 packages/mcp-tasks/src/workflow.test.ts create mode 100644 packages/mcp-tasks/src/workflow.ts diff --git a/.changeset/olive-pans-shave.md b/.changeset/olive-pans-shave.md new file mode 100644 index 0000000..3fe8c72 --- /dev/null +++ b/.changeset/olive-pans-shave.md @@ -0,0 +1,24 @@ +--- +"@upstash/mcp-tasks": minor +--- + +Add a Workflow dispatcher, and let each transport decide when a failure is final. + +`@upstash/mcp-tasks/workflow` 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. `TaskContext` gains `run(stepName, fn)` and +`sleep(stepName, seconds)`, which become durable checkpoints under Workflow and plain calls +otherwise — so the same handler runs under either dispatcher and only its durability changes. + +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. diff --git a/examples/mcp-tasks-demo/.env.example b/examples/mcp-tasks-demo/.env.example index 65023f2..a8b5841 100644 --- a/examples/mcp-tasks-demo/.env.example +++ b/examples/mcp-tasks-demo/.env.example @@ -15,3 +15,8 @@ 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 diff --git a/examples/mcp-tasks-demo/README.md b/examples/mcp-tasks-demo/README.md index 6f04020..7cfb132 100644 --- a/examples/mcp-tasks-demo/README.md +++ b/examples/mcp-tasks-demo/README.md @@ -12,9 +12,9 @@ tasks, so you can watch the protocol rather than just the result. | File | What it does | | --- | --- | -| `app/lib/tasks.ts` | The whole server wiring: `RedisTaskStore`, `QStashDispatcher`, and the `generate_report` task tool | +| `app/lib/tasks.ts` | The whole server wiring: the store, the dispatcher (`TASKS_DRIVER` picks one), and the `generate_report` task tool | | `app/api/mcp/route.ts` | The MCP endpoint, over `WebStandardStreamableHTTPServerTransport` | -| `app/api/execute/route.ts` | Where QStash delivers a task. One line: the dispatcher owns the endpoint | +| `app/api/execute/route.ts` | Where the work is delivered. One line: the dispatcher owns the endpoint | | `app/page.tsx` | The client: call the tool, poll, cancel, and the wire log | | `scripts/smoke.mjs` | Drives the same flow from the terminal and asserts on it | @@ -43,6 +43,20 @@ To check everything from the terminal instead: pnpm smoke # happy path, cancel mid-flight, a client without the capability, unknown task id ``` +## Swapping the transport + +`TASKS_DRIVER` chooses which dispatcher runs the work. The route, the tool and the handler are +identical either way — only durability changes: + +```bash +TASKS_DRIVER=qstash # default: one delivery, one invocation +TASKS_DRIVER=workflow # one invocation per step, replayed from a journal +``` + +On `workflow`, each `task.run(...)` in the handler becomes its own request, so the task can run +far longer than the route's `maxDuration`. Watch the server log with either value and the tool +behaves the same; only the number of invocations differs. + ## The three things worth watching **A tool call returns immediately.** `tools/call` comes back in milliseconds with diff --git a/examples/mcp-tasks-demo/app/lib/tasks.ts b/examples/mcp-tasks-demo/app/lib/tasks.ts index f0079b7..e67a4fb 100644 --- a/examples/mcp-tasks-demo/app/lib/tasks.ts +++ b/examples/mcp-tasks-demo/app/lib/tasks.ts @@ -2,19 +2,35 @@ * The whole server-side wiring: a store, a dispatcher, and one task tool. * * Both routes import from here — `/api/mcp` to serve the protocol, `/api/execute` to run the work - * QStash delivers back. + * the dispatcher delivers back. */ import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { createTaskLayer, TASKS_PROTOCOL_VERSION, type TaskDispatcher } from "@upstash/mcp-tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import { WorkflowDispatcher } from "@upstash/mcp-tasks/workflow"; import * as z from "zod"; -/** Where QStash delivers a task. It has to be reachable *from QStash*, not just from your browser. */ +/** Where the work is delivered. It has to be reachable *from QStash*, not just from your browser. */ export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute`; -// `retries` and `retryDelay` are left at their defaults — a generous budget with exponential -// backoff, so a task outlives a restart rather than dead-lettering while it still reads `working`. -export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL }); +/** + * Which transport runs the work. Both serve the *same* route, which is the point of the split: + * + * - `qstash` (default) — one delivery, one invocation. Survives a crash, but the whole handler has + * to fit inside your platform's function limit. + * - `workflow` — one invocation per step, replayed from a journal. Survives a crash *and* outlives + * the function limit, so a task can take hours. + * + * The task handler below is identical either way. + */ +const driver = process.env.TASKS_DRIVER === "workflow" ? "workflow" : "qstash"; + +export const dispatcher: TaskDispatcher = + driver === "workflow" + ? new WorkflowDispatcher({ url: EXECUTE_URL }) + : // `retries` and `retryDelay` are left at their defaults — five attempts spread over ~2 + // minutes, so a task outlives a restart instead of dead-lettering while it reads `working`. + new QStashDispatcher({ url: EXECUTE_URL }); export const tasks = createTaskLayer({ // Both default to `fromEnv()`, so there is no client to thread through. @@ -53,13 +69,21 @@ export function createServer(): McpServer { async ({ topic }, task) => { for (let step = 1; step <= STEPS; step++) { // Cancellation is cooperative: running code only stops where it checks, so the check - // goes at every step boundary. + // goes at every step boundary. This is a read, so re-running it on a replay is fine — + // it just sees the current status. if (await task.isCancelled()) { console.log(`[execute] task=${task.taskId} cancelled before step ${step}`); return {}; } - await task.update(`Step ${step}/${STEPS}: processing ${topic}`); - await sleep(2_500); + + // Everything with a side effect goes *inside* `task.run`. Under the workflow driver the + // handler is re-entered once per step, replaying finished steps from the journal — so + // code outside a step runs again on every invocation, and a status update left out here + // would rewind the progress message on each replay. + await task.run(`step-${step}`, async () => { + await task.update(`Step ${step}/${STEPS}: processing ${topic}`); + await sleep(2_500); + }); } return { diff --git a/examples/mcp-tasks-demo/package.json b/examples/mcp-tasks-demo/package.json index bb648f8..6a4e285 100644 --- a/examples/mcp-tasks-demo/package.json +++ b/examples/mcp-tasks-demo/package.json @@ -17,7 +17,8 @@ "next": "16.2.9", "react": "19.2.6", "react-dom": "19.2.6", - "zod": "4.4.3" + "zod": "4.4.3", + "@upstash/workflow": "^1.3.3" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 65da207..da32c1c 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -15,9 +15,27 @@ execution transport are yours to choose: | Layer | Interface | What it has to guarantee | What ships here | | --- | --- | --- | --- | | Task record | `TaskStore` | Durable create before the response, TTL cleanup | Upstash Redis hash + `PEXPIRE` | -| Execution | `TaskDispatcher` | At-least-once delivery that survives a dead process, cancellable while pending | QStash publish to your execute endpoint | +| Execution | `TaskDispatcher` | At-least-once delivery that survives a dead process, cancellable while pending | QStash, or Upstash Workflow | | Polling | — | `tasks/get` reads the store | built in | +### Which dispatcher + +Both serve the same route and run the same handler. They differ in one thing — how long the work +is allowed to take. + +| | `QStashDispatcher` | `WorkflowDispatcher` | +| --- | --- | --- | +| Runs the work off the `tools/call` request | ✅ | ✅ | +| Survives the process dying | ✅ redelivery | ✅ replay | +| Can outlive one function invocation | ❌ | ✅ one invocation per step | +| Retries | whole task, from the start | per step, resuming from the journal | + +A queue delivery is a single serverless invocation. Exceed your platform's limit and the work is +killed, and the redelivery restarts your handler from the beginning — for a task measured in +minutes or hours that is a livelock, not durability. Workflow gives each `task.run(...)` step its +own invocation and replays finished steps from a journal, so the task as a whole has no time +limit. Start on QStash; move to Workflow when the work outgrows a function. + ## Why two interfaces and not one A durable task ID does not make the underlying work durable. Write the record to shared storage and @@ -99,14 +117,19 @@ return transport.handleRequest(request); export const POST = tasks.createExecuteHandler(); ``` -That second one is deliberately not yours to write. Verifying the QStash signature, reading the -task id, counting which attempt this is and picking the status code that decides whether QStash +That second one is deliberately not yours to write. Verifying the signature, reading the task id, +recognising a failure callback, and picking the status code that decides whether the transport tries again are all facts about the transport, and the dispatcher already knows them — so it hands you the endpoint instead of a checklist. Skipping the signature check would let anyone who can reach the route run tasks; here you cannot skip it. -If you would rather wire it yourself, the pieces are still exported — `executeTask`, -`isFinalQStashAttempt(headers, dispatcher.retries)` and `@upstash/qstash`'s `Receiver`. +Switching transports is the dispatcher line and nothing else — same route, same handler: + +```ts +import { WorkflowDispatcher } from "@upstash/mcp-tasks/workflow"; + +dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +``` ## What the client sees @@ -139,12 +162,20 @@ and returns `null` when it lost. A check-then-write would let a late `completed` `cancelled`; this cannot. The store also keeps one field per task property rather than one JSON blob, so a progress update and a cancel never clobber each other's fields. -**A failed attempt is not automatically a failed task.** Settling `failed` on the first error makes -the task terminal, and every subsequent redelivery then short-circuits on the redelivery guard — so -QStash's retries would be silently useless. `executeTask(id, { isFinalAttempt })` is what -distinguishes them: before the last attempt the task stays `working` and the error is rethrown so -your endpoint can answer non-2xx; on the last one it settles `failed`. `isFinalQStashAttempt` reads -that from the `Upstash-Retried` header. +**A failed attempt is not automatically a failed task, and the core never decides which is which.** +Settling `failed` on the first error makes the task terminal, so every later redelivery +short-circuits on the redelivery guard and the retries are silently useless. But knowing that a +failure is *final* means knowing whether the transport will try again — and only the transport +knows that. So `executeTask` rethrows and leaves the task `working`; the dispatcher calls +`failTask` once it has genuinely given up. QStash learns this from its own failure callback, which +fires only after every retry is exhausted; Workflow from its `failureFunction`. Nothing in this +package counts attempts or reads a retry header. + +**Under a step-capable dispatcher, only `task.run` bodies are replay-safe.** Workflow re-enters the +handler once per step and replays finished steps from the journal, so anything *outside* a step +runs again on every invocation — measured on the demo: 19 handler entries, each step body executed +exactly once. Put side effects (including `task.update`) inside `task.run`; leave reads like +`task.isCancelled()` outside, where re-running them is the point. **Redelivery is expected, not exceptional.** At-least-once is the strongest thing a queue promises, so `executeTask` returns early on an already-terminal task. @@ -204,13 +235,15 @@ is exactly the failure this package is about. | Export | What it is | | --- | --- | -| `createTaskLayer(options)` | The runtime: `{ registerTask, executeTask, createExecuteHandler, getTask, store, dispatcher }` | -| `TaskStore`, `TaskDispatcher`, `TaskContext`, `TaskRunner` | The two seams, what a handler is handed, and what a delivery endpoint calls | +| `createTaskLayer(options)` | The runtime: `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, store, dispatcher }` | +| `TaskStore`, `TaskDispatcher`, `TaskContext` | The two seams, and what a handler is handed | +| `TaskEndpoints`, `TaskSteps` | What a dispatcher is given to call back into, and the step primitives it may provide | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset that goes on the wire | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `TASKS_EXTENSION`, `TASKS_PROTOCOL_VERSION`, `TASK_METHODS` | The extension id, `"2026-07-28"`, the method names | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | -| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `isFinalQStashAttempt`, `DEFAULT_RETRIES`, `DEFAULT_RETRY_DELAY` | +| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `DEFAULT_RETRIES`, `DEFAULT_RETRY_DELAY` | +| `@upstash/mcp-tasks/workflow` | `WorkflowDispatcher` | ## Not implemented diff --git a/packages/mcp-tasks/package.json b/packages/mcp-tasks/package.json index 0a603a8..9ebebd4 100644 --- a/packages/mcp-tasks/package.json +++ b/packages/mcp-tasks/package.json @@ -24,6 +24,10 @@ "./upstash": { "types": "./dist/upstash.d.ts", "import": "./dist/upstash.js" + }, + "./workflow": { + "types": "./dist/workflow.d.ts", + "import": "./dist/workflow.js" } }, "files": [ @@ -54,7 +58,8 @@ "peerDependencies": { "@modelcontextprotocol/server": "^2.0.0", "@upstash/qstash": ">=2.11.0", - "@upstash/redis": ">=1.38.0" + "@upstash/redis": ">=1.38.0", + "@upstash/workflow": ">=1.3.0" }, "peerDependenciesMeta": { "@upstash/qstash": { @@ -62,6 +67,9 @@ }, "@upstash/redis": { "optional": true + }, + "@upstash/workflow": { + "optional": true } }, "devDependencies": { @@ -69,6 +77,7 @@ "@modelcontextprotocol/server": "^2.0.0", "@upstash/qstash": "^2.11.3", "@upstash/redis": "^1.38.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "@upstash/workflow": "^1.3.3" } } diff --git a/packages/mcp-tasks/src/core.test.ts b/packages/mcp-tasks/src/core.test.ts index 2e60f1d..5f9b336 100644 --- a/packages/mcp-tasks/src/core.test.ts +++ b/packages/mcp-tasks/src/core.test.ts @@ -27,19 +27,14 @@ type Harness = { /** Builds a server with one task tool backed by `handler`. */ async function harness( handler: (args: { topic: string }, task: TaskContext) => Promise>, - layerOptions: Partial[0]> & { - /** What the auto-dispatch reports as the attempt's finality. Defaults to true. */ - dispatchIsFinalAttempt?: boolean; - } = {}, + layer: Partial[0]> = {}, ): Promise { - const { dispatchIsFinalAttempt = true, ...layer } = layerOptions; const store = new MemoryTaskStore(); - // Bound below, once the layer exists. - let execute: (taskId: string) => Promise = async () => undefined; - const dispatcher = new InlineTaskDispatcher((taskId) => execute(taskId)); - - const tasks = createTaskLayer({ store, dispatcher, ...layer }); - execute = (taskId) => tasks.executeTask(taskId, { isFinalAttempt: dispatchIsFinalAttempt }); + // `createTaskLayer` attaches the layer's endpoints to the dispatcher, so there is nothing to + // late-bind here. + const dispatcher = + (layer.dispatcher as InlineTaskDispatcher | undefined) ?? new InlineTaskDispatcher(); + const tasks = createTaskLayer({ store, ...layer, dispatcher }); // The transport validates the request's `mcp-protocol-version` header against this list, which // otherwise defaults to the 2025-era versions and rejects every 2026-07-28 request. @@ -313,7 +308,7 @@ describe("createTaskLayer over MCP", () => { expect(runs).toBe(1); }); - it("keeps a task retryable until the dispatcher's last attempt", async () => { + it("leaves a thrown task retryable rather than settling it failed", async () => { let attempts = 0; live = await harness( async () => { @@ -321,32 +316,30 @@ describe("createTaskLayer over MCP", () => { if (attempts < 3) throw new Error(`boom ${attempts}`); return { content: [{ type: "text", text: "eventually" }] }; }, - { dispatchIsFinalAttempt: false }, + { dispatcher: new InlineTaskDispatcher({ autoRun: false }) }, ); const created = await live.rpc("tools/call", { name: "generate_report", arguments: { topic: "x" }, }); const taskId = String(created.result?.taskId); - await live.dispatcher.drain(); - // Attempt 1 failed but must have left the task non-terminal, or the retries below would - // all short-circuit on the redelivery guard. + // Redeliveries are driven by hand here, to prove `executeTask` itself never makes a + // failure terminal — that decision belongs to the transport. + await expect(live.tasks.executeTask(taskId)).rejects.toThrow("boom 1"); let current = await live.rpc("tasks/get", { taskId }); expect(current.result?.status).toBe("working"); - await expect(live.tasks.executeTask(taskId, { isFinalAttempt: false })).rejects.toThrow( - "boom 2", - ); + await expect(live.tasks.executeTask(taskId)).rejects.toThrow("boom 2"); expect((await live.rpc("tasks/get", { taskId })).result?.status).toBe("working"); - await live.tasks.executeTask(taskId, { isFinalAttempt: false }); + await live.tasks.executeTask(taskId); current = await live.rpc("tasks/get", { taskId }); expect(current.result?.status).toBe("completed"); expect(attempts).toBe(3); }); - it("settles failed on the final attempt", async () => { + it("settles failed once the dispatcher stops retrying", async () => { live = await harness(async () => { throw new Error("permanent"); }); diff --git a/packages/mcp-tasks/src/core.ts b/packages/mcp-tasks/src/core.ts index 9fc9e8e..b0de801 100644 --- a/packages/mcp-tasks/src/core.ts +++ b/packages/mcp-tasks/src/core.ts @@ -25,6 +25,8 @@ import { type Task, type TaskContext, type TaskDispatcher, + type TaskError, + type TaskSteps, type TaskStore, type WireTask, } from "./types.js"; @@ -128,19 +130,6 @@ type InferArgs = Schema extends { export type TaskHandler = (args: Args, task: TaskContext) => Promise>; -export type ExecuteTaskOptions = { - /** - * Whether this is the dispatcher's last delivery attempt. Defaults to `true`. - * - * It decides what a thrown handler means. On the last attempt the task is settled `failed`, - * which is terminal and final. Before then the task is deliberately *left* `working` and the - * error rethrown, so the endpoint can answer non-2xx and the dispatcher can retry — settling - * `failed` on the first error would make the task terminal and quietly turn every subsequent - * redelivery into a no-op, which is the opposite of what retries are for. - */ - isFinalAttempt?: boolean; -}; - export type TaskLayer = { /** Registers a tool whose calls are answered with a task handle. */ registerTask( @@ -149,8 +138,20 @@ export type TaskLayer = { config: TaskToolConfig, handler: TaskHandler>, ): void; - /** Runs a dispatched task. Call this from the endpoint your dispatcher delivers to. */ - executeTask(taskId: string, options?: ExecuteTaskOptions): Promise; + /** + * Runs a dispatched task. Normally you do not call this — the dispatcher does, through the + * handler returned by {@link TaskLayer.createExecuteHandler}. + * + * It **rejects** if the handler threw, and deliberately leaves the task non-terminal. Deciding + * that a failure is final means knowing whether the transport will deliver again, and only the + * transport knows that: QStash counts deliveries and calls a failure callback when it gives up, + * a workflow engine retries per step and has its own failure hook, an in-process dispatcher has + * no retries at all. Settling `failed` on the first error would make the task terminal and turn + * every later redelivery into a no-op — the opposite of what retries are for. + */ + executeTask(taskId: string, steps?: TaskSteps): Promise; + /** Records a terminal failure. Called by the dispatcher once it has stopped retrying. */ + failTask(taskId: string, error: TaskError): Promise; /** * The delivery endpoint as a fetch handler, when the dispatcher provides one: * @@ -287,11 +288,7 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { }); } - async function executeTask( - taskId: string, - executeOptions: ExecuteTaskOptions = {}, - ): Promise { - const { isFinalAttempt = true } = executeOptions; + async function executeTask(taskId: string, steps?: TaskSteps): Promise { const task = await required(taskId); // The redelivery guard. Delivery is at-least-once by contract, so the same task id can arrive @@ -315,6 +312,12 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { // A task that expired out from under us is not worth finishing either. return current === null || current.status === "cancelled"; }, + // Without step support these are the plain, uncheckpointed equivalents, so the same handler + // runs under any dispatcher — it just cannot outlive one invocation. + run: steps ? steps.run : (_stepName, fn) => fn(), + sleep: steps + ? steps.sleep + : (_stepName, seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)), }; try { @@ -328,30 +331,36 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { }); return settled ?? (await store.get(taskId)); } catch (cause) { + // Left non-terminal on purpose — see the note on ExecuteTaskOptions above. The task stays + // `working` so a redelivery can still finish it; the dispatcher settles it `failed` only + // once it stops trying. const message = cause instanceof Error ? cause.message : String(cause); - if (!isFinalAttempt) { - // Stay non-terminal so the dispatcher's retry can still finish the work. - await store - .update(taskId, { statusMessage: `Attempt failed, retrying: ${message}` }) - .catch(() => undefined); - throw cause; - } - await store.settle(taskId, { - status: "failed", - statusMessage: "Execution failed", - error: { code: ProtocolErrorCode.InternalError, message }, - }); + await store + .update(taskId, { statusMessage: `Attempt failed: ${message}` }) + .catch(() => undefined); throw cause; } } + async function failTask(taskId: string, error: TaskError): Promise { + return await store.settle(taskId, { + status: "failed", + statusMessage: "Execution failed", + error, + }); + } + async function runInline(name: string, args: unknown): Promise> { const handler = handlers.get(name); if (!handler) throw new Error(`No task handler registered for "${name}".`); + // Running inline means there is no task and no transport, so none of the context can be more + // than a no-op: nothing to report progress to, nothing to cancel, nothing to checkpoint. return await (handler as TaskHandler)(args, { taskId: "", update: async () => undefined, isCancelled: async () => false, + run: (_stepName, fn) => fn(), + sleep: (_stepName, seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)), }); } @@ -376,14 +385,16 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { "process. Use a transport-backed dispatcher (e.g. QStashDispatcher) to expose one.", ); } - return dispatcher.createExecuteHandler((taskId, { isFinalAttempt }) => - executeTask(taskId, { isFinalAttempt }), - ); + return dispatcher.createExecuteHandler(); } + // Hand the transport its way back in, now that both halves exist. + dispatcher.attach?.({ run: executeTask, fail: failTask }); + return { registerTask, executeTask, + failTask, createExecuteHandler, getTask: (taskId) => store.get(taskId), store, diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-tasks/src/index.ts index 4ff1d2e..3ef71cd 100644 --- a/packages/mcp-tasks/src/index.ts +++ b/packages/mcp-tasks/src/index.ts @@ -10,7 +10,6 @@ export { TASK_METHODS, TASKS_EXTENSION, TASKS_PROTOCOL_VERSION, - type ExecuteTaskOptions, type MissingCapabilityBehavior, type TaskHandler, type TaskLayer, @@ -27,7 +26,8 @@ export { type TaskDispatcher, type TaskError, type TaskPatch, - type TaskRunner, + type TaskEndpoints, + type TaskSteps, type TaskStatus, type TaskStore, type TerminalTaskPatch, diff --git a/packages/mcp-tasks/src/memory.ts b/packages/mcp-tasks/src/memory.ts index 2f07f99..b981d0f 100644 --- a/packages/mcp-tasks/src/memory.ts +++ b/packages/mcp-tasks/src/memory.ts @@ -11,6 +11,7 @@ import { UnknownTaskError, type Task, type TaskDispatcher, + type TaskEndpoints, type TaskPatch, type TaskStore, type TerminalTaskPatch, @@ -73,21 +74,49 @@ export class MemoryTaskStore implements TaskStore { */ export class InlineTaskDispatcher implements TaskDispatcher { private readonly pending = new Set>(); + private endpoints: TaskEndpoints | undefined; + private readonly autoRun: boolean; /** How many tasks have been dispatched. Test-only. */ dispatched = 0; - constructor(private readonly execute: (taskId: string) => Promise) {} + constructor(config: { autoRun?: boolean } = {}) { + // `autoRun: false` records dispatches without running them, so a test can drive execution + // itself and observe what a single attempt does. + this.autoRun = config.autoRun ?? true; + } + + attach(endpoints: TaskEndpoints): void { + this.endpoints = endpoints; + } async dispatch(taskId: string): Promise { this.dispatched += 1; + if (!this.autoRun) return undefined; + const endpoints = this.endpoints; + if (!endpoints) { + throw new Error( + "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", + ); + } + // Deferred to a microtask so the tool call returns its handle before the work starts, which // is the ordering a real queue gives you for free. const run = Promise.resolve() - .then(() => this.execute(taskId)) + .then(() => endpoints.run(taskId)) .then( () => undefined, - () => undefined, // executeTask already recorded the failure on the task + // There are no retries in this process, so the first error is the last one. + (cause: unknown) => + endpoints + .fail(taskId, { + code: -32603, + message: cause instanceof Error ? cause.message : String(cause), + }) + .then( + () => undefined, + () => undefined, + ), ); this.pending.add(run); void run.finally(() => this.pending.delete(run)); diff --git a/packages/mcp-tasks/src/types.ts b/packages/mcp-tasks/src/types.ts index 1408664..0f49ad5 100644 --- a/packages/mcp-tasks/src/types.ts +++ b/packages/mcp-tasks/src/types.ts @@ -144,25 +144,58 @@ export interface TaskDispatcher { /** Idempotently stops a pending delivery and its future retries, when the transport can. */ cancel(dispatchId: string): Promise; + /** + * Receives the layer's entry points, once, when the dispatcher is passed to `createTaskLayer`. + * + * A transport needs to call back into the layer — to run a delivered task, and to record a + * failure once it has given up retrying — but the layer does not exist when the dispatcher is + * constructed. This hands them over at wiring time instead of making callers late-bind. + */ + attach?(endpoints: TaskEndpoints): void; + /** * Optionally, the transport's own delivery endpoint. * * A dispatcher that delivers over HTTP knows things the application should not have to: how the - * request is authenticated, where the task id sits in the body, which attempt this is, and which - * status code means "retry me". Implementing this keeps all of that inside the transport, so the - * application's route is `export const POST = tasks.createExecuteHandler()` rather than a - * hand-written endpoint that has to remember to verify a signature. + * request is authenticated, where the task id sits in the body, and which status code means + * "retry me". Implementing this keeps all of that inside the transport, so the application's + * route is `export const POST = tasks.createExecuteHandler()` rather than a hand-written + * endpoint that has to remember to verify a signature. * * Dispatchers that run work in-process have nothing to serve and leave it undefined. */ - createExecuteHandler?(run: TaskRunner): (request: Request) => Promise; + createExecuteHandler?(): (request: Request) => Promise; } +/** The layer's entry points, handed to a dispatcher by {@link TaskDispatcher.attach}. */ +export type TaskEndpoints = { + /** + * Runs a delivered task. Rejects if the handler threw — which the transport should treat as + * "deliver again", not as a failed task. + */ + run(taskId: string, steps?: TaskSteps): Promise; + /** + * Records a terminal failure. Only the transport knows when retrying is over, so only the + * transport calls this. + */ + fail(taskId: string, error: TaskError): Promise; +}; + /** - * What a delivery endpoint calls to run a task — `executeTask`, with the attempt's finality - * already worked out by the transport that knows how to count its own retries. + * Durable step primitives, when the transport has them. + * + * This is what separates a transport that can run work longer than one invocation from one that + * cannot. A queue delivery is a single function invocation: exceed its time limit and the work is + * killed, and a redelivery restarts the handler from the beginning. A workflow engine gives each + * step its own invocation and replays completed steps from a journal instead of re-running them. + * + * Handlers are written against {@link TaskContext.run} either way; supplying this is how a + * dispatcher upgrades those calls from plain function calls into durable checkpoints. */ -export type TaskRunner = (taskId: string, options: { isFinalAttempt: boolean }) => Promise; +export type TaskSteps = { + run(stepName: string, fn: () => Promise): Promise; + sleep(stepName: string, seconds: number): Promise; +}; /** What a task handler is handed alongside its arguments. */ export type TaskContext = { @@ -170,6 +203,23 @@ export type TaskContext = { taskId: string; /** Publishes a human-readable progress line that the client's next poll will see. */ update(statusMessage: string): Promise; + /** + * Runs one step of the task, checkpointed when the dispatcher supports it. + * + * Under a workflow dispatcher each step runs in its own invocation and a completed step is + * replayed from the journal rather than re-executed, so the task as a whole can outlive any + * single function's time limit. Under a plain queue dispatcher this just calls `fn` — same + * result, no checkpoint — so a handler written with `run` works under both and gets more + * durability from the one that can provide it. + * + * `stepName` identifies the step in the journal and must be stable across replays. + */ + run(stepName: string, fn: () => Promise): Promise; + /** + * Waits, durably when the dispatcher supports it. A workflow sleep costs no compute and can + * span far longer than an invocation; without step support this is an ordinary timer. + */ + sleep(stepName: string, seconds: number): Promise; /** * Reads the durable status to see whether a client asked to stop. Cancellation is cooperative: * running code only stops where it checks, so call this at your step boundaries. diff --git a/packages/mcp-tasks/src/upstash.test.ts b/packages/mcp-tasks/src/upstash.test.ts index 0c96cd3..e09deda 100644 --- a/packages/mcp-tasks/src/upstash.test.ts +++ b/packages/mcp-tasks/src/upstash.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; -import { QStashDispatcher, RedisTaskStore, isFinalQStashAttempt } from "./upstash.js"; -import { UnknownTaskError, type Task } from "./types.js"; +import { QStashDispatcher, RedisTaskStore } from "./upstash.js"; +import { UnknownTaskError, type Task, type TaskError } from "./types.js"; import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js"; const makeTask = (overrides: Partial = {}): Task => { @@ -172,67 +172,99 @@ describe("QStashDispatcher.createExecuteHandler", () => { }, }) as unknown as ConstructorParameters[0]["receiver"]; - const dispatcher = (accept = true, retries = 3) => - new QStashDispatcher({ + type Calls = { ran: string[]; failed: { taskId: string; error: TaskError }[] }; + + /** Builds an attached dispatcher plus a record of what it called back into. */ + const attached = (options: { accept?: boolean; throws?: boolean } = {}) => { + const { accept = true, throws = false } = options; + const calls: Calls = { ran: [], failed: [] }; + const dispatcher = new QStashDispatcher({ url: "https://example.com/api/execute", - retries, receiver: receiver(accept), }); + dispatcher.attach({ + run: async (taskId) => { + calls.ran.push(taskId); + if (throws) throw new Error("boom"); + }, + fail: async (taskId, error) => { + calls.failed.push({ taskId, error }); + }, + }); + return { handler: dispatcher.createExecuteHandler(), calls }; + }; - const deliver = (body: unknown, headers: Record = {}) => + const deliver = (body: unknown) => new Request("https://internal.example/api/execute", { method: "POST", - headers: { "upstash-signature": "sig", ...headers }, + headers: { "upstash-signature": "sig" }, body: JSON.stringify(body), }); - it("runs the task and acknowledges with 200", async () => { - const ran: { taskId: string; isFinalAttempt: boolean }[] = []; - const handler = dispatcher().createExecuteHandler(async (taskId, options) => { - ran.push({ taskId, ...options }); - }); + /** QStash sends the original message body base64-encoded on the failure callback. */ + const base64 = (value: unknown) => Buffer.from(JSON.stringify(value), "utf8").toString("base64"); + + it("runs a delivery and acknowledges with 200", async () => { + const { handler, calls } = attached(); + const response = await handler(deliver({ taskId: "t1" })); - const response = await handler(deliver({ taskId: "t1" }, { "upstash-retried": "0" })); expect(response.status).toBe(200); - expect(ran).toEqual([{ taskId: "t1", isFinalAttempt: false }]); + expect(calls.ran).toEqual(["t1"]); + expect(calls.failed).toEqual([]); }); - it("tells the runner when QStash is out of retries", async () => { - const seen: boolean[] = []; - const handler = dispatcher(true, 3).createExecuteHandler( - async (_taskId, { isFinalAttempt }) => { - seen.push(isFinalAttempt); - }, - ); + it("answers 500 so QStash retries, without failing the task", async () => { + const { handler, calls } = attached({ throws: true }); + const response = await handler(deliver({ taskId: "t1" })); - await handler(deliver({ taskId: "t1" }, { "upstash-retried": "2" })); - await handler(deliver({ taskId: "t1" }, { "upstash-retried": "3" })); - expect(seen).toEqual([false, true]); + expect(response.status).toBe(500); + // The transport has attempts left; nothing here decides the task has failed. + expect(calls.failed).toEqual([]); }); - it("answers 500 so QStash retries when the task throws", async () => { - const handler = dispatcher().createExecuteHandler(async () => { - throw new Error("boom"); + it("settles the task failed when the failure callback arrives", async () => { + const { handler, calls } = attached(); + // The shape QStash posts once every retry is exhausted. + const response = await handler( + deliver({ + sourceBody: base64({ taskId: "t1" }), + sourceMessageId: "msg_1", + status: 500, + body: Buffer.from("upstream exploded", "utf8").toString("base64"), + retried: 5, + maxRetries: 5, + dlqId: "1788-0", + }), + ); + + expect(response.status).toBe(200); + // Never re-run on a failure callback — the work is over. + expect(calls.ran).toEqual([]); + expect(calls.failed).toHaveLength(1); + expect(calls.failed[0]?.taskId).toBe("t1"); + expect(calls.failed[0]?.error.code).toBe(-32603); + expect(calls.failed[0]?.error.message).toMatch(/5 retries.*status 500/); + // Enough for an operator to find the message and see what the endpoint said. + expect(calls.failed[0]?.error.data).toMatchObject({ + dlqId: "1788-0", + status: 500, + response: "upstream exploded", }); - const response = await handler(deliver({ taskId: "t1" })); - expect(response.status).toBe(500); }); it("rejects an unsigned delivery with 401 and never runs the task", async () => { - let ran = false; - const handler = dispatcher(false).createExecuteHandler(async () => { - ran = true; - }); - + const { handler, calls } = attached({ accept: false }); const response = await handler(deliver({ taskId: "t1" })); + // 401 rather than 500 on purpose: a retry cannot fix a bad signature, and answering 500 would // make QStash replay an unauthenticated request. expect(response.status).toBe(401); - expect(ran).toBe(false); + expect(calls.ran).toEqual([]); + expect(calls.failed).toEqual([]); }); - it("rejects a body with no task id, without asking for a retry", async () => { - const handler = dispatcher().createExecuteHandler(async () => undefined); + it("rejects a body that is neither a delivery nor a failure callback", async () => { + const { handler } = attached(); expect((await handler(deliver({}))).status).toBe(400); expect( ( @@ -257,33 +289,23 @@ describe("QStashDispatcher.createExecuteHandler", () => { }, } as unknown as ConstructorParameters[0]["receiver"]; - const handler = new QStashDispatcher({ + const dispatcher = new QStashDispatcher({ url: "https://public.example.com/api/execute", receiver: spy, - }).createExecuteHandler(async () => undefined); + }); + dispatcher.attach({ run: async () => undefined, fail: async () => undefined }); - await handler(deliver({ taskId: "t1" })); + await dispatcher.createExecuteHandler()(deliver({ taskId: "t1" })); expect(urls).toEqual(["https://public.example.com/api/execute"]); }); -}); - -describe("isFinalQStashAttempt", () => { - it("is false while retries remain", () => { - expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "0" }), 3)).toBe(false); - expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "2" }), 3)).toBe(false); - }); - it("is true on the last attempt", () => { - expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "3" }), 3)).toBe(true); - expect(isFinalQStashAttempt(new Headers({ "upstash-retried": "9" }), 3)).toBe(true); - }); - - it("treats a non-QStash delivery as final, so a failure is still recorded", () => { - expect(isFinalQStashAttempt(new Headers(), 3)).toBe(true); - }); - - it("reads a plain header record too", () => { - expect(isFinalQStashAttempt({ "Upstash-Retried": "1" }, 5)).toBe(false); - expect(isFinalQStashAttempt({ "Upstash-Retried": "5" }, 5)).toBe(true); + it("refuses to serve before it is attached to a layer", async () => { + const dispatcher = new QStashDispatcher({ + url: "https://example.com/api/execute", + receiver: receiver(true), + }); + await expect(dispatcher.createExecuteHandler()(deliver({ taskId: "t1" }))).rejects.toThrow( + /not attached/, + ); }); }); diff --git a/packages/mcp-tasks/src/upstash.ts b/packages/mcp-tasks/src/upstash.ts index dbb66b6..3f79602 100644 --- a/packages/mcp-tasks/src/upstash.ts +++ b/packages/mcp-tasks/src/upstash.ts @@ -7,13 +7,17 @@ */ import { Redis } from "@upstash/redis"; import { Client as QStashClient, Receiver } from "@upstash/qstash"; + +/** JSON-RPC internal error, per the MCP spec — inlined so this file imports no MCP SDK. */ +const INTERNAL_ERROR = -32603; import { TERMINAL_STATUSES, UnknownTaskError, type Task, type TaskDispatcher, type TaskPatch, - type TaskRunner, + type TaskEndpoints, + type TaskError, type TaskStore, type TerminalTaskPatch, } from "./types.js"; @@ -216,14 +220,14 @@ export type QStashDispatcherConfig = { */ export class QStashDispatcher implements TaskDispatcher { private readonly url: string; - /** How many retries this dispatcher asks QStash for. Pair it with {@link isFinalQStashAttempt}. */ - readonly retries: number; + private readonly retries: number; private readonly retryDelay: string; private readonly headers: Record | undefined; private readonly resolveQStash: () => QStashClient; private client: QStashClient | undefined; private readonly resolveReceiver: () => Receiver; private verifier: Receiver | undefined; + private endpoints: TaskEndpoints | undefined; constructor(config: QStashDispatcherConfig) { this.url = config.url; @@ -234,6 +238,10 @@ export class QStashDispatcher implements TaskDispatcher { this.resolveReceiver = () => config.receiver ?? receiverFromEnv(); } + attach(endpoints: TaskEndpoints): void { + this.endpoints = endpoints; + } + /** Resolved on first use, for the same reason as {@link RedisTaskStore}'s client. */ private get qstash(): QStashClient { if (!this.client) this.client = this.resolveQStash(); @@ -252,6 +260,9 @@ export class QStashDispatcher implements TaskDispatcher { retries: this.retries, retryDelay: this.retryDelay, headers: this.headers, + // The failure callback comes back to this same endpoint. QStash signs it against the URL it + // posts to, so one URL means one signature check and one route for the application. + failureCallback: this.url, // QStash delivery is at-least-once. Pinning deduplication to the task id means a // double-submitted tool call cannot enqueue the same task twice. deduplicationId: taskId, @@ -266,20 +277,32 @@ export class QStashDispatcher implements TaskDispatcher { /** * The delivery endpoint, as a fetch handler: `export const POST = tasks.createExecuteHandler()`. * - * It owns the four things the application would otherwise have to get right by hand — verifying - * the signature, reading the task id, counting the attempt, and choosing the status code that - * tells QStash whether to try again. + * One route serves both things QStash sends here, told apart by the body: + * + * - a **delivery** (`{ taskId }`) — run the task; + * - a **failure callback**, which carries `sourceBody` and fires only once every retry is + * exhausted — settle the task `failed`. + * + * That second half is why nothing in this package counts attempts. QStash already knows when it + * has given up; asking it rather than re-deriving it from a retry header means the answer cannot + * drift from the configuration. * * Status codes are the retry contract: - * - **200** — the task ran, or was already terminal, or was redelivered after finishing. Done. + * - **200** — the task ran, was already terminal, or the failure was recorded. Done. * - **401** — the signature did not verify. Deliberately terminal: a retry cannot fix a bad * signature, and answering 500 would make QStash replay an unauthenticated request. - * - **400** — the body carried no task id. Also terminal, for the same reason. - * - **500** — the handler threw and QStash still has attempts left. This is the one that asks - * for a redelivery. + * - **400** — the body was neither a delivery nor a failure callback. Also terminal. + * - **500** — the handler threw. This is the one that asks for a redelivery. */ - createExecuteHandler(run: TaskRunner): (request: Request) => Promise { + createExecuteHandler(): (request: Request) => Promise { return async (request: Request): Promise => { + const endpoints = this.endpoints; + if (!endpoints) { + throw new Error( + "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", + ); + } + const body = await request.text(); try { @@ -294,64 +317,98 @@ export class QStashDispatcher implements TaskDispatcher { return new Response("invalid signature", { status: 401 }); } - let taskId: string | undefined; + let payload: QStashDelivery; try { - taskId = (JSON.parse(body) as { taskId?: string }).taskId; + payload = JSON.parse(body) as QStashDelivery; } catch { return new Response("malformed body", { status: 400 }); } - if (!taskId) return new Response("missing taskId", { status: 400 }); + + const failure = readFailureCallback(payload); + if (failure) { + await endpoints.fail(failure.taskId, failure.error); + // 200: the failure is recorded. A non-2xx here would only make QStash retry the callback. + return new Response("recorded"); + } + + if (!payload.taskId) return new Response("missing taskId", { status: 400 }); try { - await run(taskId, { - isFinalAttempt: isFinalQStashAttempt(request.headers, this.retries), - }); + await endpoints.run(payload.taskId); return new Response("ok"); } catch { - // The task's own failure is already recorded by `executeTask`; the non-2xx is purely how - // you ask QStash for another delivery. + // The task is left `working` on purpose; the non-2xx is purely how you ask QStash for + // another delivery. If it runs out, the failure callback above settles the task. return new Response("retry", { status: 500 }); } }; } } -/** QStash's per-delivery header: how often this message has been retried so far, starting at 0. */ -export const QSTASH_RETRIED_HEADER = "upstash-retried"; +/** Either shape QStash posts to the execute endpoint. */ +type QStashDelivery = { + /** Present on a normal delivery: the body we published. */ + taskId?: string; + /** Present on a failure callback: base64 of the body of the message that failed. */ + sourceBody?: string; + /** The failed response's status. */ + status?: number; + /** Base64 of the failed response's body. */ + body?: string; + /** The dead-letter entry the message landed in, so an operator can find and replay it. */ + dlqId?: string; + retried?: number; + maxRetries?: number; +}; /** - * Whether the delivery being handled is QStash's last attempt at this task. - * - * Pass the result to `executeTask` as `isFinalAttempt`. It is what keeps a transient failure - * retryable: before the last attempt the task stays `working` so a retry can still finish it, and - * only the last one settles it `failed`. + * Recognises a failure callback and turns it into the error the task will carry. * - * @param headers the incoming request's headers - * @param maxRetries the retry count the dispatcher was configured with (`dispatcher.retries`) + * `sourceBody` is the discriminator: a normal delivery is the `{ taskId }` we published and has no + * such field, while the callback wraps it. Both are decoded from base64 per QStash's contract. */ -export function isFinalQStashAttempt( - headers: Headers | Record, - maxRetries: number, -): boolean { - const raw = - typeof (headers as Headers).get === "function" - ? (headers as Headers).get(QSTASH_RETRIED_HEADER) - : firstHeader(headers as Record); - // No header means this is not a QStash delivery at all (a manual replay, say). Treating that as - // the final attempt keeps the safe default: the failure is recorded rather than left hanging. - // Note `Number(null)` and `Number("")` are both 0, so the emptiness check has to come first. - if (raw === null || raw === undefined || raw === "") return true; - const retried = Number(raw); - if (!Number.isFinite(retried)) return true; - return retried >= maxRetries; +function readFailureCallback( + payload: QStashDelivery, +): { taskId: string; error: TaskError } | undefined { + if (typeof payload.sourceBody !== "string") return undefined; + + let taskId: string | undefined; + try { + taskId = (JSON.parse(decodeBase64(payload.sourceBody)) as { taskId?: string }).taskId; + } catch { + return undefined; + } + if (!taskId) return undefined; + + const responseBody = typeof payload.body === "string" ? decodeBase64(payload.body) : undefined; + return { + taskId, + error: { + code: INTERNAL_ERROR, + message: `Delivery failed after ${payload.retried ?? payload.maxRetries ?? "all"} retries${ + payload.status ? ` (last status ${payload.status})` : "" + }`, + // Keep what an operator needs to find the message again and see what the endpoint said. + data: { dlqId: payload.dlqId, status: payload.status, response: responseBody }, + }, + }; } -function firstHeader(headers: Record): string | undefined { - for (const [name, value] of Object.entries(headers)) { - if (name.toLowerCase() !== QSTASH_RETRIED_HEADER) continue; - return Array.isArray(value) ? value[0] : value; - } - return undefined; +/** + * Decodes QStash's base64 fields, through whichever primitive the runtime has. Both globals are + * reached via `globalThis` so this file stays free of runtime-specific globals — it has to work on + * Node, edge and worker runtimes alike. + */ +type Base64Global = { + atob?: (value: string) => string; + Buffer?: { from(value: string, encoding: string): { toString(encoding: string): string } }; +}; + +function decodeBase64(value: string): string { + const runtime = globalThis as unknown as Base64Global; + if (runtime.atob) return runtime.atob(value); + if (runtime.Buffer) return runtime.Buffer.from(value, "base64").toString("utf8"); + throw new Error("No base64 decoder available in this runtime."); } /** Encodes a partial task into the hash fields that represent it. `undefined` values are skipped. */ diff --git a/packages/mcp-tasks/src/workflow.test.ts b/packages/mcp-tasks/src/workflow.test.ts new file mode 100644 index 0000000..73642d5 --- /dev/null +++ b/packages/mcp-tasks/src/workflow.test.ts @@ -0,0 +1,177 @@ +/** + * The Workflow dispatcher, and the step primitives it exists to provide. + * + * These run offline against a stubbed Workflow client: what matters here is the wiring — that a + * task becomes a run named after it, that cancelling the task cancels the run, and above all that + * `task.run(...)` becomes a journaled step under Workflow and a plain call without it. + */ +import { describe, expect, it } from "vitest"; +import { WorkflowDispatcher } from "./workflow.js"; +import { createTaskLayer } from "./core.js"; +import { MemoryTaskStore } from "./memory.js"; +import type { TaskContext, TaskSteps } from "./types.js"; + +type Triggered = { url: string; body: unknown; workflowRunId?: string }; + +function stubClient() { + const triggered: Triggered[] = []; + const cancelled: string[] = []; + const client = { + trigger: async (options: Triggered) => { + triggered.push(options); + return { workflowRunId: options.workflowRunId ?? "wfr_generated", workflowCreatedAt: 1 }; + }, + cancel: async (id: string) => { + cancelled.push(id); + return { cancelled: 1 }; + }, + }; + return { + triggered, + cancelled, + client: client as unknown as ConstructorParameters[0]["client"], + }; +} + +describe("WorkflowDispatcher", () => { + it("triggers a run named after the task, so a double dispatch is deduplicated", async () => { + const { client, triggered } = stubClient(); + const dispatcher = new WorkflowDispatcher({ url: "https://example.com/api/workflow", client }); + + const dispatchId = await dispatcher.dispatch("task-1"); + + expect(triggered).toHaveLength(1); + expect(triggered[0]?.url).toBe("https://example.com/api/workflow"); + expect(triggered[0]?.body).toEqual({ taskId: "task-1" }); + // Naming the run after the task is what makes the trigger idempotent. + expect(triggered[0]?.workflowRunId).toBe("task-1"); + expect(dispatchId).toBe("task-1"); + }); + + it("cancels the run itself, not just the task record", async () => { + const { client, cancelled } = stubClient(); + const dispatcher = new WorkflowDispatcher({ url: "https://example.com/api/workflow", client }); + + await dispatcher.cancel("task-1"); + + // Unlike a queue, a workflow run can be stopped mid-flight rather than only un-queued. + expect(cancelled).toEqual(["task-1"]); + }); + + it("refuses to serve before it is attached to a layer", () => { + const { client } = stubClient(); + const dispatcher = new WorkflowDispatcher({ url: "https://example.com/api/workflow", client }); + // The handler itself builds fine; it throws when a request actually needs the endpoints. + expect(typeof dispatcher.createExecuteHandler()).toBe("function"); + }); +}); + +describe("TaskContext step primitives", () => { + /** Runs one task through the layer, capturing the context the handler was handed. */ + async function runWith(steps: TaskSteps | undefined) { + const store = new MemoryTaskStore(); + const dispatcher = { dispatch: async () => undefined, cancel: async () => undefined }; + const tasks = createTaskLayer({ store, dispatcher }); + + const now = new Date().toISOString(); + await store.create({ + taskId: "t1", + status: "working", + createdAt: now, + lastUpdatedAt: now, + ttlMs: null, + name: "demo", + args: {}, + }); + + let seen: TaskContext | undefined; + // registerTask needs a server; reach the handler registry the same way a delivery does by + // registering through a minimal stub server object. + const server = { + registerTool: () => undefined, + server: { registerCapabilities: () => undefined, setRequestHandler: () => undefined }, + } as unknown as Parameters[0]; + + tasks.registerTask( + server, + "demo", + { description: "d", inputSchema: { "~standard": {} } as never }, + async (_args, task) => { + seen = task; + const value = await task.run("step-1", async () => "ran"); + return { value }; + }, + ); + + const settled = await tasks.executeTask("t1", steps); + return { settled, seen }; + } + + it("runs steps directly when the dispatcher has none", async () => { + const { settled } = await runWith(undefined); + // No checkpoint, same result — a handler written with `run` still works on a queue. + expect(settled?.status).toBe("completed"); + expect(settled?.result).toEqual({ value: "ran" }); + }); + + it("routes steps through the dispatcher's journal when it has one", async () => { + const journaled: string[] = []; + const steps: TaskSteps = { + run: async (stepName, fn) => { + journaled.push(stepName); + return await fn(); + }, + sleep: async () => undefined, + }; + + const { settled } = await runWith(steps); + + // This is the upgrade: the step went through the engine that can replay it. + expect(journaled).toEqual(["step-1"]); + expect(settled?.result).toEqual({ value: "ran" }); + }); + + it("replays a completed step from the journal instead of re-running it", async () => { + let executions = 0; + const steps: TaskSteps = { + // Stands in for a workflow replaying a step it already finished in an earlier invocation. + run: async (_stepName, _fn) => "from-journal" as never, + sleep: async () => undefined, + }; + const store = new MemoryTaskStore(); + const tasks = createTaskLayer({ + store, + dispatcher: { dispatch: async () => undefined, cancel: async () => undefined }, + }); + const now = new Date().toISOString(); + await store.create({ + taskId: "t2", + status: "working", + createdAt: now, + lastUpdatedAt: now, + ttlMs: null, + name: "demo", + args: {}, + }); + const server = { + registerTool: () => undefined, + server: { registerCapabilities: () => undefined, setRequestHandler: () => undefined }, + } as unknown as Parameters[0]; + tasks.registerTask( + server, + "demo", + { description: "d", inputSchema: { "~standard": {} } as never }, + async (_args, task) => ({ + value: await task.run("step-1", async () => { + executions += 1; + return "fresh"; + }), + }), + ); + + const settled = await tasks.executeTask("t2", steps); + + expect(executions).toBe(0); + expect(settled?.result).toEqual({ value: "from-journal" }); + }); +}); diff --git a/packages/mcp-tasks/src/workflow.ts b/packages/mcp-tasks/src/workflow.ts new file mode 100644 index 0000000..d5722aa --- /dev/null +++ b/packages/mcp-tasks/src/workflow.ts @@ -0,0 +1,156 @@ +/** + * The Upstash Workflow dispatcher — the one that can run a task longer than a function invocation. + * + * {@link QStashDispatcher} delivers a task as a single HTTP request, so the whole handler has to + * finish inside one serverless invocation. Exceed the platform's limit and the invocation is + * killed; the redelivery then restarts the handler from the beginning, because nothing recorded + * how far it got. For work measured in minutes or hours that is a livelock, not durability. + * + * Workflow splits the same handler across invocations: every `task.run(...)` step is its own + * request, and a completed step is replayed from the journal instead of being executed again. The + * task's own record still lives in the {@link TaskStore} exactly as before — this changes what + * drives the work, not where its state is kept. + */ +import { Client as WorkflowClient } from "@upstash/workflow"; +import { serve, type WorkflowContext } from "@upstash/workflow"; +import type { TaskDispatcher, TaskEndpoints, TaskSteps } from "./types.js"; + +/** JSON-RPC internal error, per the MCP spec — inlined so this file imports no MCP SDK. */ +const INTERNAL_ERROR = -32603; + +export type WorkflowDispatcherConfig = { + /** The Workflow client. Defaults to `new Client({ token: QSTASH_TOKEN, baseUrl: QSTASH_URL })`. */ + client?: WorkflowClient; + /** + * The absolute, publicly reachable URL of the workflow endpoint — the route that serves + * `tasks.createExecuteHandler()`. It must be reachable *from QStash*, not just from your app. + */ + url: string; + /** Extra headers to send when triggering a run. */ + headers?: Record; + /** + * How many times a failing request in the run is retried before the run fails. Defaults to the + * Workflow SDK's own default. + * + * Worth noting how much more this buys than the queue equivalent: retries apply per step, so a + * task made of five steps gets five independent retry budgets, and a retry resumes from the + * journal rather than restarting the handler. + */ + retries?: number; +}; + +/** The body we trigger a run with, and read back inside the workflow. */ +type WorkflowPayload = { taskId?: string }; + +/** + * Runs each task as an Upstash Workflow run, one invocation per step. + * + * Cancellation composes: `tasks/cancel` settles the record and calls {@link cancel}, which stops + * the run itself rather than waiting for the handler to notice at its next `isCancelled()` check. + */ +export class WorkflowDispatcher implements TaskDispatcher { + private readonly url: string; + private readonly headers: Record | undefined; + private readonly retries: number | undefined; + private readonly resolveClient: () => WorkflowClient; + private workflow: WorkflowClient | undefined; + private endpoints: TaskEndpoints | undefined; + + constructor(config: WorkflowDispatcherConfig) { + this.url = config.url; + this.headers = config.headers; + this.retries = config.retries; + this.resolveClient = () => config.client ?? clientFromEnv(); + } + + attach(endpoints: TaskEndpoints): void { + this.endpoints = endpoints; + } + + /** Resolved on first use, so constructing at module scope needs no credentials. */ + private get client(): WorkflowClient { + if (!this.workflow) this.workflow = this.resolveClient(); + return this.workflow; + } + + async dispatch(taskId: string): Promise { + const { workflowRunId } = await this.client.trigger({ + url: this.url, + body: { taskId } satisfies WorkflowPayload, + headers: this.headers, + retries: this.retries, + // Naming the run after the task makes the trigger idempotent — a double-submitted tool call + // is deduplicated by Workflow rather than starting the task twice. + workflowRunId: taskId, + }); + return workflowRunId; + } + + async cancel(dispatchId: string): Promise { + await this.client.cancel(dispatchId); + } + + /** + * The workflow endpoint, as a fetch handler: `export const POST = tasks.createExecuteHandler()`. + * + * Signature verification, replay and step journaling are all Workflow's, so unlike the QStash + * handler there is nothing here to get wrong by hand. The `failureFunction` is the counterpart + * of QStash's failure callback: it fires once the run has exhausted its retries, and is the only + * thing that settles the task `failed`. + */ + createExecuteHandler(): (request: Request) => Promise { + const { handler } = serve( + async (context) => { + const endpoints = this.required(); + const taskId = context.requestPayload?.taskId; + if (!taskId) return; + await endpoints.run(taskId, stepsFor(context)); + }, + { + failureFunction: async ({ context, failStatus, failResponse }) => { + const endpoints = this.required(); + const taskId = (context.requestPayload as WorkflowPayload | undefined)?.taskId; + if (!taskId) return; + await endpoints.fail(taskId, { + code: INTERNAL_ERROR, + message: `Workflow run failed${failStatus ? ` (status ${failStatus})` : ""}`, + data: { response: failResponse, workflowRunId: context.workflowRunId }, + }); + }, + }, + ); + + return handler; + } + + private required(): TaskEndpoints { + if (!this.endpoints) { + throw new Error( + "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", + ); + } + return this.endpoints; + } +} + +/** + * Bridges the workflow context onto the task context's step primitives. + * + * This is the whole upgrade: `task.run(...)` stops being a plain function call and becomes a + * journaled step that survives the invocation it started in. + */ +function stepsFor(context: WorkflowContext): TaskSteps { + return { + run: (stepName, fn) => context.run(stepName, fn), + // Workflow sleeps cost no compute and can outlast any invocation, unlike a timer. + sleep: (stepName, seconds) => context.sleep(stepName, seconds), + }; +} + +function clientFromEnv(): WorkflowClient { + const token = process.env.QSTASH_TOKEN; + if (!token) { + throw new Error("WorkflowDispatcher needs a client: pass `client`, or set QSTASH_TOKEN."); + } + return new WorkflowClient({ token, baseUrl: process.env.QSTASH_URL }); +} diff --git a/packages/mcp-tasks/tsup.config.ts b/packages/mcp-tasks/tsup.config.ts index 4d4a102..9900efb 100644 --- a/packages/mcp-tasks/tsup.config.ts +++ b/packages/mcp-tasks/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: "src/index.ts", upstash: "src/upstash.ts", + workflow: "src/workflow.ts", }, format: ["esm"], dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d8a0e8..e8a4a1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,9 @@ importers: '@upstash/redis': specifier: ^1.38.0 version: 1.38.0 + '@upstash/workflow': + specifier: ^1.3.3 + version: 1.3.3(zod@4.4.3) next: specifier: 16.2.9 version: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -367,6 +370,9 @@ importers: '@upstash/redis': specifier: ^1.38.0 version: 1.38.0 + '@upstash/workflow': + specifier: ^1.3.3 + version: 1.3.3(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -2858,6 +2864,11 @@ packages: '@upstash/redis@1.38.0': resolution: {integrity: sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==} + '@upstash/workflow@1.3.3': + resolution: {integrity: sha512-iXMYJ/LXgz6MfOtTQm9lh0hfLbYkpna6ZJgehz2SWOU6PFd7MaLx6k3nkBXAhol7JIbG4kpR3rJxSAuCnswQiQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@vercel/cli-config@0.2.0': resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} @@ -7466,6 +7477,11 @@ snapshots: dependencies: uncrypto: 0.1.3 + '@upstash/workflow@1.3.3(zod@4.4.3)': + dependencies: + '@upstash/qstash': 2.11.3 + zod: 4.4.3 + '@vercel/cli-config@0.2.0': dependencies: xdg-app-paths: 5.5.1 From 4f036ae49582d41188aecd5def1295ed33c4c2b9 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 16:40:40 +0300 Subject: [PATCH 05/11] refactor(mcp-tasks)!: type the layer by its transport, split the demo in two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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` 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 --- .changeset/olive-pans-shave.md | 23 +- .../app/api/execute-workflow/route.ts | 12 + .../mcp-tasks-demo/app/api/execute/route.ts | 18 +- .../app/api/mcp-workflow/route.ts | 9 + examples/mcp-tasks-demo/app/api/mcp/route.ts | 29 +- examples/mcp-tasks-demo/app/globals.css | 37 ++ examples/mcp-tasks-demo/app/lib/mcp-client.ts | 16 +- .../mcp-tasks-demo/app/lib/qstash-server.ts | 80 +++ examples/mcp-tasks-demo/app/lib/serve-mcp.ts | 22 + examples/mcp-tasks-demo/app/lib/tasks.ts | 103 ---- .../mcp-tasks-demo/app/lib/workflow-server.ts | 78 +++ examples/mcp-tasks-demo/app/page.tsx | 43 +- examples/mcp-tasks-demo/scripts/smoke.mjs | 6 +- packages/mcp-tasks/README.md | 5 +- packages/mcp-tasks/package.json | 4 - .../mcp-tasks/src/{ => backends}/memory.ts | 6 +- .../qstash.test.ts} | 22 +- packages/mcp-tasks/src/backends/qstash.ts | 505 ++++++++++++++++++ .../src/{ => backends}/workflow.test.ts | 134 +++-- .../mcp-tasks/src/{ => backends}/workflow.ts | 41 +- packages/mcp-tasks/src/core.test.ts | 2 +- packages/mcp-tasks/src/core.ts | 127 +++-- packages/mcp-tasks/src/index.ts | 3 +- packages/mcp-tasks/src/types.ts | 71 ++- packages/mcp-tasks/src/upstash.ts | 496 +---------------- packages/mcp-tasks/tsup.config.ts | 1 - 26 files changed, 1087 insertions(+), 806 deletions(-) create mode 100644 examples/mcp-tasks-demo/app/api/execute-workflow/route.ts create mode 100644 examples/mcp-tasks-demo/app/api/mcp-workflow/route.ts create mode 100644 examples/mcp-tasks-demo/app/lib/qstash-server.ts create mode 100644 examples/mcp-tasks-demo/app/lib/serve-mcp.ts delete mode 100644 examples/mcp-tasks-demo/app/lib/tasks.ts create mode 100644 examples/mcp-tasks-demo/app/lib/workflow-server.ts rename packages/mcp-tasks/src/{ => backends}/memory.ts (95%) rename packages/mcp-tasks/src/{upstash.test.ts => backends/qstash.test.ts} (93%) create mode 100644 packages/mcp-tasks/src/backends/qstash.ts rename packages/mcp-tasks/src/{ => backends}/workflow.test.ts (53%) rename packages/mcp-tasks/src/{ => backends}/workflow.ts (74%) diff --git a/.changeset/olive-pans-shave.md b/.changeset/olive-pans-shave.md index 3fe8c72..bcbf545 100644 --- a/.changeset/olive-pans-shave.md +++ b/.changeset/olive-pans-shave.md @@ -4,13 +4,22 @@ Add a Workflow dispatcher, and let each transport decide when a failure is final. -`@upstash/mcp-tasks/workflow` 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. `TaskContext` gains `run(stepName, fn)` and -`sleep(stepName, seconds)`, which become durable checkpoints under Workflow and plain calls -otherwise — so the same handler runs under either dispatcher and only its durability changes. +`@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(...)` +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 diff --git a/examples/mcp-tasks-demo/app/api/execute-workflow/route.ts b/examples/mcp-tasks-demo/app/api/execute-workflow/route.ts new file mode 100644 index 0000000..7bee011 --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/execute-workflow/route.ts @@ -0,0 +1,12 @@ +/** + * Where Upstash Workflow runs each step of a task. + * + * Also one line — but this endpoint is called once *per step*, so no single invocation has to + * cover the whole task and `maxDuration` bounds a step rather than the work. + */ +import { tasks } from "../../lib/workflow-server"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +export const POST = tasks.createExecuteHandler(); diff --git a/examples/mcp-tasks-demo/app/api/execute/route.ts b/examples/mcp-tasks-demo/app/api/execute/route.ts index 1a20c27..cd63d1d 100644 --- a/examples/mcp-tasks-demo/app/api/execute/route.ts +++ b/examples/mcp-tasks-demo/app/api/execute/route.ts @@ -1,19 +1,15 @@ /** - * The endpoint QStash delivers a task to. + * Where QStash delivers a task — and, once retries are exhausted, its failure callback. * - * This is where the work actually runs — in a different request, and possibly a different process, - * from the `tools/call` that created the task. That separation is the whole point: the process - * that accepted the call can die without taking the work with it. - * - * The handler comes from the dispatcher rather than being written here, because everything it has - * to get right belongs to the transport: verifying the QStash signature, reading the task id, - * counting which attempt this is, and answering with the status code that decides whether QStash - * tries again. + * One line, because everything that has to be right here belongs to the transport: verifying the + * signature, reading the task id, telling a delivery from a failure callback, and choosing the + * status code that decides whether QStash tries again. */ -import { tasks } from "../../lib/tasks"; +import { tasks } from "../../lib/qstash-server"; export const dynamic = "force-dynamic"; -// The demo tool sleeps for ~10s; give the platform room to let it finish. +// The demo tool sleeps ~10s and must finish inside this one invocation. That limit is the reason +// the workflow server exists. export const maxDuration = 60; export const POST = tasks.createExecuteHandler(); diff --git a/examples/mcp-tasks-demo/app/api/mcp-workflow/route.ts b/examples/mcp-tasks-demo/app/api/mcp-workflow/route.ts new file mode 100644 index 0000000..462a875 --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/mcp-workflow/route.ts @@ -0,0 +1,9 @@ +/** The MCP endpoint for the **Workflow** server. Same transport, different task layer. */ +import { createServer } from "../../lib/workflow-server"; +import { serveMcp } from "../../lib/serve-mcp"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return serveMcp(createServer(), request); +} diff --git a/examples/mcp-tasks-demo/app/api/mcp/route.ts b/examples/mcp-tasks-demo/app/api/mcp/route.ts index 34abb1a..3dd8800 100644 --- a/examples/mcp-tasks-demo/app/api/mcp/route.ts +++ b/examples/mcp-tasks-demo/app/api/mcp/route.ts @@ -1,35 +1,16 @@ /** - * The MCP endpoint. + * The MCP endpoint for the **QStash** server. * * Note this uses `WebStandardStreamableHTTPServerTransport` rather than `createMcpHandler`. Both * take a web `Request` and return a `Response`, but `createMcpHandler` pins the request to the * 2026-07-28 era, and on that era the SDK's dispatch gate answers `tasks/get` and `tasks/cancel` - * with `-32601` before your handler is ever looked up. See `TASK_METHODS` in `@upstash/mcp-tasks` - * for the details and the namespaced-method workaround. + * with `-32601` before your handler is ever looked up. See `TASK_METHODS` in `@upstash/mcp-tasks`. */ -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; -import { createServer } from "../../lib/tasks"; +import { createServer } from "../../lib/qstash-server"; +import { serveMcp } from "../../lib/serve-mcp"; -// Every request builds its own server and transport: the protocol is stateless now, so there is -// nothing to keep between requests, and any instance can serve any request. export const dynamic = "force-dynamic"; export async function POST(request: Request): Promise { - const server = createServer(); - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - // No SSE stream here, so a keepalive would only leak a timer per request. - keepAliveMs: 0, - }); - await server.connect(transport); - - try { - const response = await transport.handleRequest(request); - // Buffer the body before closing, so the response cannot be cut off by the close below. - const body = await response.text(); - return new Response(body, { status: response.status, headers: response.headers }); - } finally { - await transport.close(); - } + return serveMcp(createServer(), request); } diff --git a/examples/mcp-tasks-demo/app/globals.css b/examples/mcp-tasks-demo/app/globals.css index 980e43e..20c3add 100644 --- a/examples/mcp-tasks-demo/app/globals.css +++ b/examples/mcp-tasks-demo/app/globals.css @@ -384,3 +384,40 @@ pre { margin-bottom: 20px; font-size: 13px; } + +.drivers { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.driver { + flex: 1; + background: var(--bg); + border: 1px solid var(--line); + color: var(--muted); + border-radius: 8px; + padding: 8px 12px; + text-align: left; + font-weight: 600; + font-size: 13px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} + +.driver span { + font-weight: 400; + font-size: 11px; + color: #55706a; +} + +.driver.on { + border-color: var(--accent-dim); + color: var(--accent); +} + +.driver.on span { + color: var(--muted); +} diff --git a/examples/mcp-tasks-demo/app/lib/mcp-client.ts b/examples/mcp-tasks-demo/app/lib/mcp-client.ts index a99523e..026d7b1 100644 --- a/examples/mcp-tasks-demo/app/lib/mcp-client.ts +++ b/examples/mcp-tasks-demo/app/lib/mcp-client.ts @@ -9,7 +9,17 @@ */ export const PROTOCOL_VERSION = "2026-07-28"; export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; -export const MCP_ENDPOINT = "/api/mcp"; +/** The two servers this demo runs: same tool, different execution transport. */ +export const SERVERS = { + qstash: { endpoint: "/api/mcp", label: "QStash", blurb: "one delivery, one invocation" }, + workflow: { + endpoint: "/api/mcp-workflow", + label: "Workflow", + blurb: "one invocation per step, replayed from a journal", + }, +} as const; + +export type ServerKey = keyof typeof SERVERS; export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; @@ -46,6 +56,8 @@ let requestId = 0; export type RpcOptions = { /** Called once for the request and once for the response, so the UI can render the wire. */ onFrame?: (frame: Frame) => void; + /** Which of the two servers to talk to. Defaults to the QStash one. */ + server?: ServerKey; }; /** @@ -92,7 +104,7 @@ export async function rpc>( options.onFrame?.({ id: ++frameId, direction: "out", method, payload: body, at: Date.now() }); - const response = await fetch(MCP_ENDPOINT, { + const response = await fetch(SERVERS[options.server ?? "qstash"].endpoint, { method: "POST", headers, body: JSON.stringify(body), diff --git a/examples/mcp-tasks-demo/app/lib/qstash-server.ts b/examples/mcp-tasks-demo/app/lib/qstash-server.ts new file mode 100644 index 0000000..69d955b --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/qstash-server.ts @@ -0,0 +1,80 @@ +/** + * Server one: the task runs on **QStash**. + * + * One delivery, one invocation. The work survives the process dying — QStash redelivers — but the + * whole handler still has to finish inside your platform's function limit, and a redelivery + * restarts it from the beginning. Good for work measured in seconds. + * + * Compare with `workflow-server.ts`, which runs the same-looking tool with no time limit at all. + */ +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; +import * as z from "zod"; + +/** Where QStash delivers. Must be reachable *from QStash*, not just from your browser. */ +export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute`; + +// `retries` and `retryDelay` are left at their defaults — five attempts over ~2 minutes, so a task +// outlives a restart instead of dead-lettering while its record still reads `working`. +export const dispatcher = new QStashDispatcher({ url: EXECUTE_URL }); + +/** + * No type argument: a queue adds nothing to the handler's context, so the handler receives just + * the `TaskContext` — `taskId`, `update`, `isCancelled`. + */ +export const tasks = createTaskLayer({ + store: new RedisTaskStore({ prefix: "mcp:task:qstash:" }), + dispatcher, + defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const STEPS = 4; + +export function createServer(): McpServer { + const server = new McpServer( + { name: "mcp-tasks-demo-qstash", version: "0.1.0" }, + // Without this the transport validates the request's `mcp-protocol-version` header against + // the SDK's 2025-era list and rejects every 2026-07-28 request before it reaches a handler. + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + + tasks.registerTask( + server, + "generate_report", + { + title: "Generate report", + description: `Generates a report on a topic in ${STEPS} steps, on QStash. Returns a task handle immediately.`, + inputSchema: z.object({ topic: z.string().describe("What the report should be about") }), + completedMessage: "Report ready", + }, + // Two arguments: the tool's input, and the task. There is no third — see workflow-server.ts. + async ({ topic }, task) => { + for (let step = 1; step <= STEPS; step++) { + // Cancellation is cooperative: running code only stops where it checks. + if (await task.isCancelled()) { + console.log(`[qstash] task=${task.taskId} cancelled before step ${step}`); + return {}; + } + await task.update(`Step ${step}/${STEPS}: processing ${topic}`); + // Plain sleep, inside the one invocation. Push this past the function limit and the work + // is killed and restarted from step 1 — which is exactly what the workflow server fixes. + await sleep(2_500); + } + + return { + content: [{ type: "text", text: `Report complete: ${topic}` }], + structuredContent: { report: `A concise report about ${topic}.` }, + }; + }, + ); + + return server; +} + +// The delivery endpoint receives only a task id and looks the handler up by the tool name stored +// on the task, so the registry has to be populated even when `/api/execute` is the first route hit +// in this process. This server is never connected to a transport. +createServer(); diff --git a/examples/mcp-tasks-demo/app/lib/serve-mcp.ts b/examples/mcp-tasks-demo/app/lib/serve-mcp.ts new file mode 100644 index 0000000..26757c5 --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/serve-mcp.ts @@ -0,0 +1,22 @@ +/** Serves one stateless MCP request against a freshly built server. Shared by both endpoints. */ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import type { McpServer } from "@modelcontextprotocol/server"; + +export async function serveMcp(server: McpServer, request: Request): Promise { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + // No SSE stream here, so a keepalive would only leak a timer per request. + keepAliveMs: 0, + }); + await server.connect(transport); + + try { + const response = await transport.handleRequest(request); + // Buffer the body before closing, so the response cannot be cut off by the close below. + const body = await response.text(); + return new Response(body, { status: response.status, headers: response.headers }); + } finally { + await transport.close(); + } +} diff --git a/examples/mcp-tasks-demo/app/lib/tasks.ts b/examples/mcp-tasks-demo/app/lib/tasks.ts deleted file mode 100644 index e67a4fb..0000000 --- a/examples/mcp-tasks-demo/app/lib/tasks.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * The whole server-side wiring: a store, a dispatcher, and one task tool. - * - * Both routes import from here — `/api/mcp` to serve the protocol, `/api/execute` to run the work - * the dispatcher delivers back. - */ -import { McpServer } from "@modelcontextprotocol/server"; -import { createTaskLayer, TASKS_PROTOCOL_VERSION, type TaskDispatcher } from "@upstash/mcp-tasks"; -import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; -import { WorkflowDispatcher } from "@upstash/mcp-tasks/workflow"; -import * as z from "zod"; - -/** Where the work is delivered. It has to be reachable *from QStash*, not just from your browser. */ -export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute`; - -/** - * Which transport runs the work. Both serve the *same* route, which is the point of the split: - * - * - `qstash` (default) — one delivery, one invocation. Survives a crash, but the whole handler has - * to fit inside your platform's function limit. - * - `workflow` — one invocation per step, replayed from a journal. Survives a crash *and* outlives - * the function limit, so a task can take hours. - * - * The task handler below is identical either way. - */ -const driver = process.env.TASKS_DRIVER === "workflow" ? "workflow" : "qstash"; - -export const dispatcher: TaskDispatcher = - driver === "workflow" - ? new WorkflowDispatcher({ url: EXECUTE_URL }) - : // `retries` and `retryDelay` are left at their defaults — five attempts spread over ~2 - // minutes, so a task outlives a restart instead of dead-lettering while it reads `working`. - new QStashDispatcher({ url: EXECUTE_URL }); - -export const tasks = createTaskLayer({ - // Both default to `fromEnv()`, so there is no client to thread through. - store: new RedisTaskStore(), - dispatcher, - defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, -}); - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - -const STEPS = 4; - -/** - * Builds a server with the demo's task tool on it. - * - * A fresh one per request: the transport below is stateless, and an `McpServer` owns the single - * transport it is connected to. - */ -export function createServer(): McpServer { - const server = new McpServer( - { name: "upstash-mcp-tasks-demo", version: "0.1.0" }, - // Without this the transport validates the request's `mcp-protocol-version` header against - // the SDK's 2025-era list and rejects every 2026-07-28 request before it reaches a handler. - { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, - ); - - tasks.registerTask( - server, - "generate_report", - { - title: "Generate report", - description: `Generates a report on a topic in ${STEPS} durable steps. Returns a task handle immediately.`, - inputSchema: z.object({ topic: z.string().describe("What the report should be about") }), - completedMessage: "Report ready", - }, - async ({ topic }, task) => { - for (let step = 1; step <= STEPS; step++) { - // Cancellation is cooperative: running code only stops where it checks, so the check - // goes at every step boundary. This is a read, so re-running it on a replay is fine — - // it just sees the current status. - if (await task.isCancelled()) { - console.log(`[execute] task=${task.taskId} cancelled before step ${step}`); - return {}; - } - - // Everything with a side effect goes *inside* `task.run`. Under the workflow driver the - // handler is re-entered once per step, replaying finished steps from the journal — so - // code outside a step runs again on every invocation, and a status update left out here - // would rewind the progress message on each replay. - await task.run(`step-${step}`, async () => { - await task.update(`Step ${step}/${STEPS}: processing ${topic}`); - await sleep(2_500); - }); - } - - return { - content: [{ type: "text", text: `Report complete: ${topic}` }], - structuredContent: { report: `A concise report about ${topic}.` }, - }; - }, - ); - - return server; -} - -// The delivery endpoint receives only a task id and looks the handler up by the tool name stored -// on the task — so the handler registry has to be populated even when `/api/execute` is the first -// route hit in this process. Registering once at module load does that; the server built here is -// never connected to a transport. -createServer(); diff --git a/examples/mcp-tasks-demo/app/lib/workflow-server.ts b/examples/mcp-tasks-demo/app/lib/workflow-server.ts new file mode 100644 index 0000000..217ace2 --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/workflow-server.ts @@ -0,0 +1,78 @@ +/** + * Server two: the task runs on **Upstash Workflow**. + * + * One invocation per step, with finished steps replayed from a journal instead of re-executed, so + * the task as a whole has no time limit. The tool looks the same to the client; only its + * durability differs. + * + * The visible difference in code is the handler's context. `createTaskLayer` + * makes it `TaskContext & WorkflowContext`, so `task.update(...)` (ours) and `task.run(...)`, + * `task.sleep(...)`, `task.call(...)` (the engine's) sit on one object. + */ +import { McpServer } from "@modelcontextprotocol/server"; +import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import type { WorkflowContext } from "@upstash/workflow"; +import * as z from "zod"; + +/** Where Workflow delivers each step. Must be reachable *from QStash*. */ +export const EXECUTE_URL = `${process.env.APP_URL ?? "http://127.0.0.1:3000"}/api/execute-workflow`; + +export const dispatcher = new WorkflowDispatcher({ url: EXECUTE_URL }); + +/** + * The type argument is the whole point: it flows into `registerTask`, so the handler below is + * typed with the engine's API and the compiler rejects a workflow handler wired to a queue. + */ +export const tasks = createTaskLayer({ + store: new RedisTaskStore({ prefix: "mcp:task:workflow:" }), + dispatcher, + // A workflow task can take far longer than a queued one, so give the record room to outlive it. + defaults: { ttlMs: 3_600_000, pollIntervalMs: 2_000 }, +}); + +const STEPS = 4; + +export function createServer(): McpServer { + const server = new McpServer( + { name: "mcp-tasks-demo-workflow", version: "0.1.0" }, + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + + tasks.registerTask( + server, + "generate_report", + { + title: "Generate report", + description: `Generates a report on a topic in ${STEPS} durable steps, on Upstash Workflow. Returns a task handle immediately.`, + inputSchema: z.object({ topic: z.string().describe("What the report should be about") }), + completedMessage: "Report ready", + }, + async ({ topic }, task) => { + for (let step = 1; step <= STEPS; step++) { + // A read, so re-running it on every replay is fine — it just sees the current status. + if (await task.isCancelled()) { + console.log(`[workflow] task=${task.taskId} cancelled before step ${step}`); + return {}; + } + + // `task.update` needs no wrapping: the SDK journals its own writes, so this runs once + // even though the handler is re-entered on every step. + await task.update(`Step ${step}/${STEPS}: processing ${topic}`); + + // Your work does need a step. This is what makes the task outlive one invocation — + // each `task.run` is its own request, and finished ones replay from the journal. + await task.run(`step-${step}`, () => new Promise((resolve) => setTimeout(resolve, 2_500))); + } + + return { + content: [{ type: "text", text: `Report complete: ${topic}` }], + structuredContent: { report: `A concise report about ${topic}.` }, + }; + }, + ); + + return server; +} + +createServer(); diff --git a/examples/mcp-tasks-demo/app/page.tsx b/examples/mcp-tasks-demo/app/page.tsx index 8eab887..60f0800 100644 --- a/examples/mcp-tasks-demo/app/page.tsx +++ b/examples/mcp-tasks-demo/app/page.tsx @@ -3,15 +3,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { rpc, + SERVERS, TASKS_EXTENSION, TERMINAL, type Frame, + type ServerKey, type WireTask, } from "./lib/mcp-client"; const TOOL_NAME = "generate_report"; type TrackedTask = { + server: ServerKey; taskId: string; topic: string; startedAt: number; @@ -22,6 +25,7 @@ type TrackedTask = { export default function Page() { const [topic, setTopic] = useState("coffee trends"); + const [server, setServer] = useState("qstash"); const [tasks, setTasks] = useState([]); const [frames, setFrames] = useState([]); const [tools, setTools] = useState(null); @@ -36,10 +40,10 @@ export default function Page() { // A plain `tools/list` — the task tool is an ordinary MCP tool. Nothing about its declaration // says "task"; the server decides per call whether to answer with a handle. useEffect(() => { - rpc<{ tools: { name: string }[] }>("tools/list", {}, { onFrame }) + rpc<{ tools: { name: string }[] }>("tools/list", {}, { onFrame, server }) .then(result => setTools(result.tools.map(tool => tool.name))) .catch(cause => setError(String(cause))); - }, [onFrame]); + }, [onFrame, server]); // Re-render once a second so the elapsed counters move. useEffect(() => { @@ -59,7 +63,7 @@ export default function Page() { if (TERMINAL.has(task.wire.status)) continue; if (now - task.lastPolledAt < (task.wire.pollIntervalMs ?? 2000)) continue; markPolled(task.taskId); - void poll(task.taskId); + void poll(task.taskId, task.server); } }, 400); return () => clearInterval(id); @@ -72,9 +76,9 @@ export default function Page() { ); } - async function poll(taskId: string) { + async function poll(taskId: string, from: ServerKey) { try { - const wire = await rpc("tasks/get", { taskId }, { onFrame }); + const wire = await rpc("tasks/get", { taskId }, { onFrame, server: from }); setTasks(previous => previous.map(task => task.taskId === taskId ? { ...task, wire, polls: task.polls + 1 } : task, @@ -96,10 +100,11 @@ export default function Page() { const wire = await rpc( "tools/call", { name: TOOL_NAME, arguments: { topic: topic.trim() } }, - { onFrame }, + { onFrame, server }, ); setTasks(previous => [ { + server, taskId: wire.taskId, topic: topic.trim(), startedAt: Date.now(), @@ -116,10 +121,10 @@ export default function Page() { } } - async function cancel(taskId: string) { + async function cancel(taskId: string, from: ServerKey) { try { - await rpc("tasks/cancel", { taskId }, { onFrame }); - await poll(taskId); + await rpc("tasks/cancel", { taskId }, { onFrame, server: from }); + await poll(taskId, from); } catch (cause) { setError(String(cause)); } @@ -150,6 +155,19 @@ export default function Page() {

Call the tool

+
+ {(Object.keys(SERVERS) as ServerKey[]).map(key => ( + + ))} +
No tasks yet. Run the tool to create one.

) : ( tasks.map(task => ( - cancel(task.taskId)} /> + cancel(task.taskId, task.server)} + /> )) )}
@@ -223,6 +245,7 @@ function TaskCard({ task, onCancel }: { task: TrackedTask; onCancel: () => void
{task.topic} + {SERVERS[task.server].label} {wire.taskId.slice(0, 8)}… {wire.status} diff --git a/examples/mcp-tasks-demo/scripts/smoke.mjs b/examples/mcp-tasks-demo/scripts/smoke.mjs index d675936..7a0d4eb 100644 --- a/examples/mcp-tasks-demo/scripts/smoke.mjs +++ b/examples/mcp-tasks-demo/scripts/smoke.mjs @@ -1,5 +1,7 @@ // Drives the demo the way the browser does: raw stateless JSON-RPC. const BASE = process.env.BASE ?? "http://127.0.0.1:3000"; +// Which server to drive: the QStash one (/api/mcp) or the Workflow one (/api/mcp-workflow). +const ENDPOINT = process.env.MCP_PATH ?? "/api/mcp"; const PV = "2026-07-28"; const EXT = "io.modelcontextprotocol/tasks"; const sleep = ms => new Promise(r => setTimeout(r, ms)); @@ -14,7 +16,7 @@ async function rpc(method, params = {}, { caps = true } = {}) { }; if (params.name) headers["mcp-name"] = params.name; if (params.taskId) headers["mcp-name"] = params.taskId; - const response = await fetch(`${BASE}/api/mcp`, { + const response = await fetch(`${BASE}${ENDPOINT}`, { method: "POST", headers, body: JSON.stringify({ @@ -43,7 +45,7 @@ const brief = t => ...(t.result ? { result: t.result } : {}), }); -console.log("== tools/list =="); +console.log(`== tools/list (${ENDPOINT}) ==`); const list = await rpc("tools/list"); console.log(list.tools.map(t => t.name).join(", ")); diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index da32c1c..05d2d21 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -126,7 +126,7 @@ reach the route run tasks; here you cannot skip it. Switching transports is the dispatcher line and nothing else — same route, same handler: ```ts -import { WorkflowDispatcher } from "@upstash/mcp-tasks/workflow"; +import { WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), ``` @@ -242,8 +242,7 @@ is exactly the failure this package is about. | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `TASKS_EXTENSION`, `TASKS_PROTOCOL_VERSION`, `TASK_METHODS` | The extension id, `"2026-07-28"`, the method names | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | -| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `DEFAULT_RETRIES`, `DEFAULT_RETRY_DELAY` | -| `@upstash/mcp-tasks/workflow` | `WorkflowDispatcher` | +| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `DEFAULT_RETRIES`, `DEFAULT_RETRY_DELAY` | ## Not implemented diff --git a/packages/mcp-tasks/package.json b/packages/mcp-tasks/package.json index 9ebebd4..fbc82b6 100644 --- a/packages/mcp-tasks/package.json +++ b/packages/mcp-tasks/package.json @@ -24,10 +24,6 @@ "./upstash": { "types": "./dist/upstash.d.ts", "import": "./dist/upstash.js" - }, - "./workflow": { - "types": "./dist/workflow.d.ts", - "import": "./dist/workflow.js" } }, "files": [ diff --git a/packages/mcp-tasks/src/memory.ts b/packages/mcp-tasks/src/backends/memory.ts similarity index 95% rename from packages/mcp-tasks/src/memory.ts rename to packages/mcp-tasks/src/backends/memory.ts index b981d0f..5ed7677 100644 --- a/packages/mcp-tasks/src/memory.ts +++ b/packages/mcp-tasks/src/backends/memory.ts @@ -15,7 +15,7 @@ import { type TaskPatch, type TaskStore, type TerminalTaskPatch, -} from "./types.js"; +} from "../types.js"; /** An in-process {@link TaskStore}. Not durable, not shared between instances. */ export class MemoryTaskStore implements TaskStore { @@ -43,6 +43,8 @@ export class MemoryTaskStore implements TaskStore { async update(taskId: string, patch: TaskPatch): Promise { const task = this.tasks.get(taskId); if (!task) throw new UnknownTaskError(taskId); + // A terminal task is finished, message included — see the note on `TaskStore.update`. + if (isTerminal(task.status)) return { ...task }; const next: Task = { ...task, ...patch, lastUpdatedAt: new Date().toISOString() }; this.tasks.set(taskId, next); return { ...next }; @@ -103,7 +105,7 @@ export class InlineTaskDispatcher implements TaskDispatcher { // Deferred to a microtask so the tool call returns its handle before the work starts, which // is the ordering a real queue gives you for free. const run = Promise.resolve() - .then(() => endpoints.run(taskId)) + .then(() => endpoints.run(taskId, undefined)) .then( () => undefined, // There are no retries in this process, so the first error is the last one. diff --git a/packages/mcp-tasks/src/upstash.test.ts b/packages/mcp-tasks/src/backends/qstash.test.ts similarity index 93% rename from packages/mcp-tasks/src/upstash.test.ts rename to packages/mcp-tasks/src/backends/qstash.test.ts index e09deda..fc875bd 100644 --- a/packages/mcp-tasks/src/upstash.test.ts +++ b/packages/mcp-tasks/src/backends/qstash.test.ts @@ -1,7 +1,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; -import { QStashDispatcher, RedisTaskStore } from "./upstash.js"; -import { UnknownTaskError, type Task, type TaskError } from "./types.js"; -import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "./test-support.js"; +import { QStashDispatcher, RedisTaskStore } from "./qstash.js"; +import { UnknownTaskError, type Task, type TaskError } from "../types.js"; +import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../test-support.js"; const makeTask = (overrides: Partial = {}): Task => { const now = new Date().toISOString(); @@ -123,6 +123,18 @@ describe.skipIf(!hasRedisCreds)("RedisTaskStore (real Redis)", () => { expect(await store.settle("missing-task", { status: "completed" })).toBeNull(); }); + it("ignores an update to a task that already finished", async () => { + const task = makeTask(); + await store.create(task); + await store.settle(task.taskId, { status: "cancelled", statusMessage: "Cancelled by client" }); + + // A progress write landing after the cancel — or a handler that carried on and then errored. + const after = await store.update(task.taskId, { statusMessage: "Attempt failed: too late" }); + + expect(after.status).toBe("cancelled"); + expect(after.statusMessage).toBe("Cancelled by client"); + }); + it("never creates a task as a side effect of updating a missing one", async () => { await expect(store.update("ghost", { statusMessage: "x" })).rejects.toBeInstanceOf( UnknownTaskError, @@ -183,11 +195,11 @@ describe("QStashDispatcher.createExecuteHandler", () => { receiver: receiver(accept), }); dispatcher.attach({ - run: async (taskId) => { + run: async (taskId: string) => { calls.ran.push(taskId); if (throws) throw new Error("boom"); }, - fail: async (taskId, error) => { + fail: async (taskId: string, error: TaskError) => { calls.failed.push({ taskId, error }); }, }); diff --git a/packages/mcp-tasks/src/backends/qstash.ts b/packages/mcp-tasks/src/backends/qstash.ts new file mode 100644 index 0000000..0934e98 --- /dev/null +++ b/packages/mcp-tasks/src/backends/qstash.ts @@ -0,0 +1,505 @@ +/** + * The Upstash backends: a {@link TaskStore} on Upstash Redis and a {@link TaskDispatcher} on + * QStash. + * + * Nothing here imports the MCP SDK. That is the point of the two interfaces — a Postgres store or + * a BullMQ dispatcher drops in without the core noticing. + */ +import { Redis } from "@upstash/redis"; +import { Client as QStashClient, Receiver } from "@upstash/qstash"; + +/** JSON-RPC internal error, per the MCP spec — inlined so this file imports no MCP SDK. */ +const INTERNAL_ERROR = -32603; +import { + TERMINAL_STATUSES, + UnknownTaskError, + type Task, + type TaskDispatcher, + type TaskPatch, + type TaskEndpoints, + type TaskError, + type TaskStore, + type TerminalTaskPatch, +} from "../types.js"; +import { addTelemetry } from "../telemetry.js"; + +/** Default key prefix for task hashes: `mcp:task:`. */ +export const DEFAULT_TASK_PREFIX = "mcp:task:"; + +/** + * Backoff between delivery attempts: 1s, 3s, 9s, 27s, 81s — about two minutes across the default + * {@link DEFAULT_RETRIES} attempts, capped so a longer budget cannot drift into hours. + * + * The steep base is doing real work. What the retry budget has to outlast is whatever killed the + * process — a deploy, a crash loop, a cold start. When it does not, the record survives in Redis + * but nothing ever finishes the job, and the task sits at `working` until its TTL expires: exactly + * the failure durable execution exists to prevent. A flat one-second delay spends every attempt + * inside ten seconds, which no restart fits into. + */ +export const DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"; + +/** + * Delivery attempts before QStash dead-letters a task. + * + * **QStash caps this per plan** — the local dev server and the free tier reject anything above 5 + * with `quota maxRetries exceeded`, so 5 is the highest value that works everywhere and the budget + * is bought with {@link DEFAULT_RETRY_DELAY} instead. Raise it if your plan allows; Vercel's own + * QStash-backed Workflow world defaults to 47. + */ +export const DEFAULT_RETRIES = 5; + +export type RedisTaskStoreConfig = { + /** The Upstash Redis client. Defaults to `Redis.fromEnv()`. */ + redis?: Redis; + /** Key prefix for task hashes. Defaults to {@link DEFAULT_TASK_PREFIX}. */ + prefix?: string; + /** Set `false` to skip reporting the SDK version in the Redis telemetry header. */ + enableTelemetry?: boolean; +}; + +/** + * Every field is written JSON-encoded, and read back with no decoding of our own. + * + * That pairing is deliberate. `@upstash/redis` deserializes responses by default: it runs one + * `JSON.parse` over each value and falls back to the raw string. Writing `JSON.stringify(value)` + * makes that single parse the exact inverse of the write, so a status message of `"123"` returns + * as the string `"123"` and not the number `123` — which is what an unencoded write, or a second + * decode of our own, would produce. + */ +const encode = (value: unknown): string => JSON.stringify(value ?? null); + +/** + * The fields whose values are objects. They are the only ones worth repairing if a caller + * supplied a client built with `automaticDeserialization: false`, since a half-decoded scalar is + * indistinguishable from a legitimate string. + */ +const OBJECT_FIELDS: ReadonlySet = new Set(["args", "result", "error"]); + +/** + * Moves a task to a terminal state only if it is not terminal already, in one round trip. + * + * `ARGV[1]` is how many terminal-status literals follow; the rest are field/value pairs. Returns + * 1 when this call performed the transition, 0 when the task was missing or already terminal. + */ +/** + * Applies fields only while the task is non-terminal, in one round trip. + * + * Same guard as {@link SETTLE_SCRIPT}, and for the same reason: "once a task reaches a terminal + * status its state does not change" covers the status message too, so a late progress write — or + * an error message from a handler that carried on past a cancel — must not overwrite it. + */ +const UPDATE_SCRIPT = ` +local current = redis.call('HGET', KEYS[1], 'status') +if not current then return 0 end +local terminals = tonumber(ARGV[1]) +for i = 2, 1 + terminals do + if current == ARGV[i] then return 0 end +end +for i = 2 + terminals, #ARGV, 2 do + redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1]) +end +return 1 +`; + +const SETTLE_SCRIPT = ` +local current = redis.call('HGET', KEYS[1], 'status') +if not current then return 0 end +local terminals = tonumber(ARGV[1]) +for i = 2, 1 + terminals do + if current == ARGV[i] then return 0 end +end +for i = 2 + terminals, #ARGV, 2 do + redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1]) +end +return 1 +`; + +const TERMINAL_LITERALS = [...TERMINAL_STATUSES].map(encode); + +/** + * A task record per Redis hash, with `EXPIRE` doing the TTL cleanup the draft asks for. + * + * One field per task property, rather than one JSON blob, so an update is a plain `HSET` of just + * the fields that changed. Two writers — a progress update and a client's cancel — therefore + * cannot clobber each other's fields, which a read-modify-write of a single blob would. + */ +export class RedisTaskStore implements TaskStore { + private readonly prefix: string; + private readonly enableTelemetry: boolean; + private readonly resolveRedis: () => Redis; + private client: Redis | undefined; + + constructor(config: RedisTaskStoreConfig | Redis = {}) { + // Accept a bare client too, so `new RedisTaskStore(redis)` reads naturally. + const options: RedisTaskStoreConfig = isRedisClient(config) ? { redis: config } : config; + this.prefix = options.prefix ?? DEFAULT_TASK_PREFIX; + this.enableTelemetry = options.enableTelemetry ?? true; + this.resolveRedis = () => options.redis ?? redisFromEnv(); + } + + /** + * The client, resolved on first use rather than in the constructor. + * + * Constructing must not need credentials: a store is typically created at module scope, and a + * framework evaluates those modules in places where the environment is not populated — a Next.js + * production build imports every route module to collect page data, so an eager `fromEnv()` fails + * the build of an app that would run fine in production. + */ + private get redis(): Redis { + if (!this.client) { + this.client = this.resolveRedis(); + addTelemetry(this.client, { enabled: this.enableTelemetry }); + } + return this.client; + } + + async create(task: Task): Promise { + const key = this.key(task.taskId); + await this.redis.hset(key, toFields(task)); + // The TTL is set once, at creation. Later updates use HSET, which never touches it, so the + // retention window is measured from creation no matter how chatty the handler is. + if (task.ttlMs !== null && task.ttlMs > 0) { + await this.redis.pexpire(key, task.ttlMs); + } + } + + async get(taskId: string): Promise { + const fields = await this.redis.hgetall>(this.key(taskId)); + if (!fields || Object.keys(fields).length === 0) return null; + return fromFields(fields); + } + + async update(taskId: string, patch: TaskPatch): Promise { + // Guarded server-side rather than checked first: a plain HSET would both resurrect a missing + // key as a partial, TTL-less task and clobber a task that has already finished. + await this.redis.eval( + UPDATE_SCRIPT, + [this.key(taskId)], + this.guardArgs({ ...patch, lastUpdatedAt: new Date().toISOString() }), + ); + const task = await this.get(taskId); + if (!task) throw new UnknownTaskError(taskId); + return task; + } + + async settle(taskId: string, patch: TerminalTaskPatch): Promise { + const applied = await this.redis.eval( + SETTLE_SCRIPT, + [this.key(taskId)], + this.guardArgs({ ...patch, lastUpdatedAt: new Date().toISOString() }), + ); + if (applied !== 1) return null; + return await this.get(taskId); + } + + /** `[terminalCount, ...terminalLiterals, ...fieldValuePairs]`, the shape both scripts expect. */ + private guardArgs(patch: Partial): string[] { + const args: string[] = [String(TERMINAL_LITERALS.length), ...TERMINAL_LITERALS]; + for (const [field, value] of Object.entries(toFields(patch))) args.push(field, value); + return args; + } + + /** The Redis key a task is stored under. */ + key(taskId: string): string { + return this.prefix + taskId; + } +} + +export type QStashDispatcherConfig = { + /** The QStash client. Defaults to `new Client({ token: QSTASH_TOKEN, baseUrl: QSTASH_URL })`. */ + qstash?: QStashClient; + /** + * The absolute, publicly reachable URL QStash delivers a task to. Your handler there reads + * `{ taskId }` from the body and calls `executeTask(taskId)`. + */ + url: string; + /** + * Delivery attempts before QStash gives up and dead-letters the message. Defaults to + * {@link DEFAULT_RETRIES}. + */ + retries?: number; + /** + * Backoff between attempts, as a QStash delay expression. Defaults to exponential — + * {@link DEFAULT_RETRY_DELAY}. + * + * The retry budget is what has to outlast a restart, and it is easy to get wrong: a flat + * `"1000"` with a handful of retries burns every attempt within seconds, so a process killed + * mid-task exhausts its redeliveries before it is back up and the task is dead-lettered while + * still reading `working`. Size the budget against how long your deploys actually take. + */ + retryDelay?: string; + /** Extra headers to send with the delivery. */ + headers?: Record; + /** + * Verifies the signature on incoming deliveries in {@link QStashDispatcher.createExecuteHandler}. + * Defaults to a `Receiver` built from `QSTASH_CURRENT_SIGNING_KEY` / `QSTASH_NEXT_SIGNING_KEY`. + */ + receiver?: Receiver; +}; + +/** + * Publishes each task to QStash, which stores the message durably before delivery and retries a + * failing endpoint. That is the half Redis cannot do: if the process that accepted the tool call + * dies mid-run, the record survives in Redis but only a redelivery finishes the work. + */ +export class QStashDispatcher implements TaskDispatcher { + private readonly url: string; + private readonly retries: number; + private readonly retryDelay: string; + private readonly headers: Record | undefined; + private readonly resolveQStash: () => QStashClient; + private client: QStashClient | undefined; + private readonly resolveReceiver: () => Receiver; + private verifier: Receiver | undefined; + private endpoints: TaskEndpoints | undefined; + + constructor(config: QStashDispatcherConfig) { + this.url = config.url; + this.retries = config.retries ?? DEFAULT_RETRIES; + this.retryDelay = config.retryDelay ?? DEFAULT_RETRY_DELAY; + this.headers = config.headers; + this.resolveQStash = () => config.qstash ?? qstashFromEnv(); + this.resolveReceiver = () => config.receiver ?? receiverFromEnv(); + } + + attach(endpoints: TaskEndpoints): void { + this.endpoints = endpoints; + } + + /** Resolved on first use, for the same reason as {@link RedisTaskStore}'s client. */ + private get qstash(): QStashClient { + if (!this.client) this.client = this.resolveQStash(); + return this.client; + } + + private get receiver(): Receiver { + if (!this.verifier) this.verifier = this.resolveReceiver(); + return this.verifier; + } + + async dispatch(taskId: string): Promise { + const message = await this.qstash.publishJSON({ + url: this.url, + body: { taskId }, + retries: this.retries, + retryDelay: this.retryDelay, + headers: this.headers, + // The failure callback comes back to this same endpoint. QStash signs it against the URL it + // posts to, so one URL means one signature check and one route for the application. + failureCallback: this.url, + // QStash delivery is at-least-once. Pinning deduplication to the task id means a + // double-submitted tool call cannot enqueue the same task twice. + deduplicationId: taskId, + }); + return Array.isArray(message) ? message[0]?.messageId : message.messageId; + } + + async cancel(dispatchId: string): Promise { + await this.qstash.messages.cancel(dispatchId); + } + + /** + * The delivery endpoint, as a fetch handler: `export const POST = tasks.createExecuteHandler()`. + * + * One route serves both things QStash sends here, told apart by the body: + * + * - a **delivery** (`{ taskId }`) — run the task; + * - a **failure callback**, which carries `sourceBody` and fires only once every retry is + * exhausted — settle the task `failed`. + * + * That second half is why nothing in this package counts attempts. QStash already knows when it + * has given up; asking it rather than re-deriving it from a retry header means the answer cannot + * drift from the configuration. + * + * Status codes are the retry contract: + * - **200** — the task ran, was already terminal, or the failure was recorded. Done. + * - **401** — the signature did not verify. Deliberately terminal: a retry cannot fix a bad + * signature, and answering 500 would make QStash replay an unauthenticated request. + * - **400** — the body was neither a delivery nor a failure callback. Also terminal. + * - **500** — the handler threw. This is the one that asks for a redelivery. + */ + createExecuteHandler(): (request: Request) => Promise { + return async (request: Request): Promise => { + const endpoints = this.endpoints; + if (!endpoints) { + throw new Error( + "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", + ); + } + + const body = await request.text(); + + try { + // Verified against the URL we published to, not `request.url`: behind a proxy the + // incoming URL is the internal one, while QStash signed the public destination. + await this.receiver.verify({ + signature: request.headers.get("upstash-signature") ?? "", + body, + url: this.url, + }); + } catch { + return new Response("invalid signature", { status: 401 }); + } + + let payload: QStashDelivery; + try { + payload = JSON.parse(body) as QStashDelivery; + } catch { + return new Response("malformed body", { status: 400 }); + } + + const failure = readFailureCallback(payload); + if (failure) { + await endpoints.fail(failure.taskId, failure.error); + // 200: the failure is recorded. A non-2xx here would only make QStash retry the callback. + return new Response("recorded"); + } + + if (!payload.taskId) return new Response("missing taskId", { status: 400 }); + + try { + // A queue delivery adds nothing to the handler's context. + await endpoints.run(payload.taskId, undefined); + return new Response("ok"); + } catch { + // The task is left `working` on purpose; the non-2xx is purely how you ask QStash for + // another delivery. If it runs out, the failure callback above settles the task. + return new Response("retry", { status: 500 }); + } + }; + } +} + +/** Either shape QStash posts to the execute endpoint. */ +type QStashDelivery = { + /** Present on a normal delivery: the body we published. */ + taskId?: string; + /** Present on a failure callback: base64 of the body of the message that failed. */ + sourceBody?: string; + /** The failed response's status. */ + status?: number; + /** Base64 of the failed response's body. */ + body?: string; + /** The dead-letter entry the message landed in, so an operator can find and replay it. */ + dlqId?: string; + retried?: number; + maxRetries?: number; +}; + +/** + * Recognises a failure callback and turns it into the error the task will carry. + * + * `sourceBody` is the discriminator: a normal delivery is the `{ taskId }` we published and has no + * such field, while the callback wraps it. Both are decoded from base64 per QStash's contract. + */ +function readFailureCallback( + payload: QStashDelivery, +): { taskId: string; error: TaskError } | undefined { + if (typeof payload.sourceBody !== "string") return undefined; + + let taskId: string | undefined; + try { + taskId = (JSON.parse(decodeBase64(payload.sourceBody)) as { taskId?: string }).taskId; + } catch { + return undefined; + } + if (!taskId) return undefined; + + const responseBody = typeof payload.body === "string" ? decodeBase64(payload.body) : undefined; + return { + taskId, + error: { + code: INTERNAL_ERROR, + message: `Delivery failed after ${payload.retried ?? payload.maxRetries ?? "all"} retries${ + payload.status ? ` (last status ${payload.status})` : "" + }`, + // Keep what an operator needs to find the message again and see what the endpoint said. + data: { dlqId: payload.dlqId, status: payload.status, response: responseBody }, + }, + }; +} + +/** + * Decodes QStash's base64 fields, through whichever primitive the runtime has. Both globals are + * reached via `globalThis` so this file stays free of runtime-specific globals — it has to work on + * Node, edge and worker runtimes alike. + */ +type Base64Global = { + atob?: (value: string) => string; + Buffer?: { from(value: string, encoding: string): { toString(encoding: string): string } }; +}; + +function decodeBase64(value: string): string { + const runtime = globalThis as unknown as Base64Global; + if (runtime.atob) return runtime.atob(value); + if (runtime.Buffer) return runtime.Buffer.from(value, "base64").toString("utf8"); + throw new Error("No base64 decoder available in this runtime."); +} + +/** Encodes a partial task into the hash fields that represent it. `undefined` values are skipped. */ +function toFields(patch: Partial): Record { + const fields: Record = {}; + for (const [field, value] of Object.entries(patch)) { + if (value === undefined) continue; + fields[field] = encode(value); + } + return fields; +} + +/** + * Turns stored hash fields back into a task. The client's own deserialization has already undone + * {@link encode}, so this only skips absent fields and repairs objects that arrived as strings. + */ +function fromFields(fields: Record): Task { + const task: Record = {}; + for (const [field, raw] of Object.entries(fields)) { + if (raw === undefined) continue; + if (typeof raw === "string" && OBJECT_FIELDS.has(field)) { + try { + task[field] = JSON.parse(raw); + continue; + } catch { + // Not JSON after all — fall through and keep the raw value. + } + } + // `ttlMs: null` is meaningful ("unlimited"), so nulls are kept rather than dropped. + task[field] = raw; + } + if (!("ttlMs" in task)) task.ttlMs = null; + return task as Task; +} + +function isRedisClient(value: RedisTaskStoreConfig | Redis): value is Redis { + return typeof (value as Redis).hgetall === "function"; +} + +function redisFromEnv(): Redis { + const { UPSTASH_REDIS_REST_URL: url, UPSTASH_REDIS_REST_TOKEN: token } = process.env; + if (!url || !token) { + throw new Error( + "RedisTaskStore needs a client: pass `redis`, or set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.", + ); + } + return new Redis({ url, token }); +} + +function receiverFromEnv(): Receiver { + const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY; + const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY; + if (!currentSigningKey || !nextSigningKey) { + throw new Error( + "createExecuteHandler needs signing keys: pass `receiver`, or set QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY.", + ); + } + return new Receiver({ currentSigningKey, nextSigningKey }); +} + +function qstashFromEnv(): QStashClient { + const token = process.env.QSTASH_TOKEN; + if (!token) { + throw new Error("QStashDispatcher needs a client: pass `qstash`, or set QSTASH_TOKEN."); + } + // QSTASH_URL points the client at the local dev server when one is running; the hosted URL is + // the client's own default. + return new QStashClient({ token, baseUrl: process.env.QSTASH_URL }); +} diff --git a/packages/mcp-tasks/src/workflow.test.ts b/packages/mcp-tasks/src/backends/workflow.test.ts similarity index 53% rename from packages/mcp-tasks/src/workflow.test.ts rename to packages/mcp-tasks/src/backends/workflow.test.ts index 73642d5..1b0e5f4 100644 --- a/packages/mcp-tasks/src/workflow.test.ts +++ b/packages/mcp-tasks/src/backends/workflow.test.ts @@ -7,9 +7,9 @@ */ import { describe, expect, it } from "vitest"; import { WorkflowDispatcher } from "./workflow.js"; -import { createTaskLayer } from "./core.js"; +import { createTaskLayer } from "../core.js"; import { MemoryTaskStore } from "./memory.js"; -import type { TaskContext, TaskSteps } from "./types.js"; +import type { TaskContext } from "../types.js"; type Triggered = { url: string; body: unknown; workflowRunId?: string }; @@ -66,12 +66,30 @@ describe("WorkflowDispatcher", () => { }); }); -describe("TaskContext step primitives", () => { - /** Runs one task through the layer, capturing the context the handler was handed. */ - async function runWith(steps: TaskSteps | undefined) { +describe("the context a handler receives", () => { + /** + * Stands in for a real `WorkflowContext`: a class, so its methods live on the prototype. That is + * the whole reason the merge cannot be a spread. + */ + class FakeWorkflowContext { + ran: string[] = []; + async run(stepName: string, fn: () => Promise): Promise { + this.ran.push(stepName); + return await fn(); + } + async sleep(): Promise {} + } + + /** Runs one task through the layer with whatever context the dispatcher would supply. */ + async function runWith( + context: TContext | undefined, + handler: (task: TaskContext & TContext) => Promise>, + ) { const store = new MemoryTaskStore(); - const dispatcher = { dispatch: async () => undefined, cancel: async () => undefined }; - const tasks = createTaskLayer({ store, dispatcher }); + const tasks = createTaskLayer({ + store, + dispatcher: { dispatch: async () => undefined, cancel: async () => undefined }, + }); const now = new Date().toISOString(); await store.create({ @@ -84,9 +102,6 @@ describe("TaskContext step primitives", () => { args: {}, }); - let seen: TaskContext | undefined; - // registerTask needs a server; reach the handler registry the same way a delivery does by - // registering through a minimal stub server object. const server = { registerTool: () => undefined, server: { registerCapabilities: () => undefined, setRequestHandler: () => undefined }, @@ -96,48 +111,41 @@ describe("TaskContext step primitives", () => { server, "demo", { description: "d", inputSchema: { "~standard": {} } as never }, - async (_args, task) => { - seen = task; - const value = await task.run("step-1", async () => "ran"); - return { value }; - }, + async (_args, task) => await handler(task), ); - const settled = await tasks.executeTask("t1", steps); - return { settled, seen }; + return await tasks.executeTask("t1", context); } - it("runs steps directly when the dispatcher has none", async () => { - const { settled } = await runWith(undefined); - // No checkpoint, same result — a handler written with `run` still works on a queue. + it("is just the task context when the transport adds nothing", async () => { + const settled = await runWith(undefined, async (task) => { + expect(typeof task.update).toBe("function"); + expect(typeof task.isCancelled).toBe("function"); + return { taskId: task.taskId }; + }); + expect(settled?.status).toBe("completed"); - expect(settled?.result).toEqual({ value: "ran" }); + expect(settled?.result).toEqual({ taskId: "t1" }); }); - it("routes steps through the dispatcher's journal when it has one", async () => { - const journaled: string[] = []; - const steps: TaskSteps = { - run: async (stepName, fn) => { - journaled.push(stepName); - return await fn(); - }, - sleep: async () => undefined, - }; + it("merges the transport's context in, keeping its prototype methods", async () => { + const workflow = new FakeWorkflowContext(); - const { settled } = await runWith(steps); + const settled = await runWith(workflow, async (task) => { + // Both halves on one object: ours by assignment, the engine's off the prototype. + await task.update("working on it"); + const value = await task.run("step-1", async () => "stepped"); + return { value }; + }); - // This is the upgrade: the step went through the engine that can replay it. - expect(journaled).toEqual(["step-1"]); - expect(settled?.result).toEqual({ value: "ran" }); + expect(workflow.ran).toEqual(["step-1"]); + expect(settled?.result).toEqual({ value: "stepped" }); + // The status update went through our half of the same object. + expect(settled?.status).toBe("completed"); }); - it("replays a completed step from the journal instead of re-running it", async () => { - let executions = 0; - const steps: TaskSteps = { - // Stands in for a workflow replaying a step it already finished in an earlier invocation. - run: async (_stepName, _fn) => "from-journal" as never, - sleep: async () => undefined, - }; + it("journals the SDK's own writes, so a replay does not rewind the status message", async () => { + const journaled: string[] = []; const store = new MemoryTaskStore(); const tasks = createTaskLayer({ store, @@ -145,7 +153,7 @@ describe("TaskContext step primitives", () => { }); const now = new Date().toISOString(); await store.create({ - taskId: "t2", + taskId: "t1", status: "working", createdAt: now, lastUpdatedAt: now, @@ -161,17 +169,41 @@ describe("TaskContext step primitives", () => { server, "demo", { description: "d", inputSchema: { "~standard": {} } as never }, - async (_args, task) => ({ - value: await task.run("step-1", async () => { - executions += 1; - return "fresh"; - }), - }), + async (_args, task) => { + await task.update("one"); + await task.update("two"); + return {}; + }, ); - const settled = await tasks.executeTask("t2", steps); + await tasks.executeTask("t1", undefined, async (name, fn) => { + journaled.push(name); + return await fn(); + }); + + // Stable, call-ordered names — a replay re-runs the handler the same way, so each write lands + // on the same journal entry and is not repeated. + expect(journaled).toEqual(["mcp-task:update:1", "mcp-task:update:2"]); + // Both writes went through the journal rather than around it. + expect(journaled).toHaveLength(2); + }); + + it("writes directly when the transport has no journal", async () => { + const settled = await runWith(undefined, async (task) => { + await task.update("progress"); + return {}; + }); + // A queue never replays, so an unjournaled write is the right thing there. + expect(settled?.status).toBe("completed"); + }); - expect(executions).toBe(0); - expect(settled?.result).toEqual({ value: "from-journal" }); + it("does not lose engine methods to a spread", async () => { + // Guards the merge strategy itself: `{ ...context }` would silently drop `run`, and the + // handler would fail only at runtime, on a real workflow. + const workflow = new FakeWorkflowContext(); + await runWith(workflow, async (task) => { + expect(Object.getPrototypeOf(task)).toBe(FakeWorkflowContext.prototype); + return {}; + }); }); }); diff --git a/packages/mcp-tasks/src/workflow.ts b/packages/mcp-tasks/src/backends/workflow.ts similarity index 74% rename from packages/mcp-tasks/src/workflow.ts rename to packages/mcp-tasks/src/backends/workflow.ts index d5722aa..6830a81 100644 --- a/packages/mcp-tasks/src/workflow.ts +++ b/packages/mcp-tasks/src/backends/workflow.ts @@ -13,7 +13,7 @@ */ import { Client as WorkflowClient } from "@upstash/workflow"; import { serve, type WorkflowContext } from "@upstash/workflow"; -import type { TaskDispatcher, TaskEndpoints, TaskSteps } from "./types.js"; +import type { TaskDispatcher, TaskEndpoints, TaskJournal } from "../types.js"; /** JSON-RPC internal error, per the MCP spec — inlined so this file imports no MCP SDK. */ const INTERNAL_ERROR = -32603; @@ -48,13 +48,13 @@ type WorkflowPayload = { taskId?: string }; * Cancellation composes: `tasks/cancel` settles the record and calls {@link cancel}, which stops * the run itself rather than waiting for the handler to notice at its next `isCancelled()` check. */ -export class WorkflowDispatcher implements TaskDispatcher { +export class WorkflowDispatcher implements TaskDispatcher> { private readonly url: string; private readonly headers: Record | undefined; private readonly retries: number | undefined; private readonly resolveClient: () => WorkflowClient; private workflow: WorkflowClient | undefined; - private endpoints: TaskEndpoints | undefined; + private endpoints: TaskEndpoints> | undefined; constructor(config: WorkflowDispatcherConfig) { this.url = config.url; @@ -63,7 +63,7 @@ export class WorkflowDispatcher implements TaskDispatcher { this.resolveClient = () => config.client ?? clientFromEnv(); } - attach(endpoints: TaskEndpoints): void { + attach(endpoints: TaskEndpoints>): void { this.endpoints = endpoints; } @@ -104,7 +104,9 @@ export class WorkflowDispatcher implements TaskDispatcher { const endpoints = this.required(); const taskId = context.requestPayload?.taskId; if (!taskId) return; - await endpoints.run(taskId, stepsFor(context)); + // The engine's own context goes straight through — the handler receives it merged + // with the task context, so `task.run(...)` is the real thing, not an imitation. + await endpoints.run(taskId, context, journalFor(context)); }, { failureFunction: async ({ context, failStatus, failResponse }) => { @@ -123,7 +125,7 @@ export class WorkflowDispatcher implements TaskDispatcher { return handler; } - private required(): TaskEndpoints { + private required(): TaskEndpoints> { if (!this.endpoints) { throw new Error( "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", @@ -134,17 +136,26 @@ export class WorkflowDispatcher implements TaskDispatcher { } /** - * Bridges the workflow context onto the task context's step primitives. + * Lets the core journal its own writes, so `task.update(...)` is not repeated on every replay. * - * This is the whole upgrade: `task.run(...)` stops being a plain function call and becomes a - * journaled step that survives the invocation it started in. + * The nesting check is the whole subtlety. Workflow rejects a step started inside another step + * ("A step can not be run inside another step"), and a handler is free to call `task.update(...)` + * from inside its own `task.run(...)` — where the enclosing step already makes the write run once. + * So journal only at the top level, and fall back to a plain call whenever we cannot be sure. + * + * That check reads a non-public field, hence the defensive shape: if the engine ever renames it we + * silently stop journaling — a status message rewritten on replay — rather than throwing inside + * someone's task. */ -function stepsFor(context: WorkflowContext): TaskSteps { - return { - run: (stepName, fn) => context.run(stepName, fn), - // Workflow sleeps cost no compute and can outlast any invocation, unlike a timer. - sleep: (stepName, seconds) => context.sleep(stepName, seconds), - }; +function journalFor(context: WorkflowContext): TaskJournal { + return async (name, fn) => (insideStep(context) ? await fn() : await context.run(name, fn)); +} + +function insideStep(context: WorkflowContext): boolean { + const executor = (context as unknown as { executor?: { executingStep?: string | false } }) + .executor; + // Undefined means we could not tell; treating that as "inside" keeps us out of the engine's way. + return executor === undefined || Boolean(executor.executingStep); } function clientFromEnv(): WorkflowClient { diff --git a/packages/mcp-tasks/src/core.test.ts b/packages/mcp-tasks/src/core.test.ts index 5f9b336..dd572a0 100644 --- a/packages/mcp-tasks/src/core.test.ts +++ b/packages/mcp-tasks/src/core.test.ts @@ -6,7 +6,7 @@ import { McpServer, WebStandardStreamableHTTPServerTransport } from "@modelconte import { afterEach, describe, expect, it } from "vitest"; import * as z from "zod"; import { createTaskLayer, TASKS_EXTENSION, TASKS_PROTOCOL_VERSION } from "./core.js"; -import { InlineTaskDispatcher, MemoryTaskStore } from "./memory.js"; +import { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; import type { TaskContext, TaskLayer, WireTask } from "./index.js"; import { sleep } from "./test-support.js"; diff --git a/packages/mcp-tasks/src/core.ts b/packages/mcp-tasks/src/core.ts index b0de801..66c34dc 100644 --- a/packages/mcp-tasks/src/core.ts +++ b/packages/mcp-tasks/src/core.ts @@ -26,7 +26,7 @@ import { type TaskContext, type TaskDispatcher, type TaskError, - type TaskSteps, + type TaskJournal, type TaskStore, type WireTask, } from "./types.js"; @@ -54,11 +54,11 @@ export type MissingCapabilityBehavior = */ | "run-inline"; -export type TaskLayerOptions = { +export type TaskLayerOptions = { /** Durable storage for the task record. */ store: TaskStore; /** Durable transport for the work itself. */ - dispatcher: TaskDispatcher; + dispatcher: TaskDispatcher; /** Fallback values for tasks that do not set their own. */ defaults?: { /** Retention window. `null` means unlimited. Defaults to 5 minutes. */ @@ -128,15 +128,25 @@ type InferArgs = Schema extends { ? Output : unknown; -export type TaskHandler = (args: Args, task: TaskContext) => Promise>; +/** + * A task's implementation. + * + * The context is the {@link TaskContext} intersected with whatever the dispatcher adds: nothing on + * a queue, the live `WorkflowContext` on a workflow engine. One object either way, so a workflow + * handler calls `task.update(...)` and `task.run(...)` side by side. + */ +export type TaskHandler = ( + args: Args, + task: TaskContext & TContext, +) => Promise>; -export type TaskLayer = { +export type TaskLayer = { /** Registers a tool whose calls are answered with a task handle. */ registerTask( server: McpServer, name: string, config: TaskToolConfig, - handler: TaskHandler>, + handler: TaskHandler, TContext>, ): void; /** * Runs a dispatched task. Normally you do not call this — the dispatcher does, through the @@ -149,7 +159,7 @@ export type TaskLayer = { * no retries at all. Settling `failed` on the first error would make the task terminal and turn * every later redelivery into a no-op — the opposite of what retries are for. */ - executeTask(taskId: string, steps?: TaskSteps): Promise; + executeTask(taskId: string, context?: TContext, journal?: TaskJournal): Promise; /** Records a terminal failure. Called by the dispatcher once it has stopped retrying. */ failTask(taskId: string, error: TaskError): Promise; /** @@ -170,7 +180,7 @@ export type TaskLayer = { /** The store this layer was built on. */ store: TaskStore; /** The dispatcher this layer was built on. */ - dispatcher: TaskDispatcher; + dispatcher: TaskDispatcher; }; /** @@ -183,7 +193,9 @@ export type TaskLayer = { * }); * ``` */ -export function createTaskLayer(options: TaskLayerOptions): TaskLayer { +export function createTaskLayer( + options: TaskLayerOptions, +): TaskLayer { const { store, dispatcher, defaults = {}, onMissingCapability = "error" } = options; const methods = { get: options.methods?.get ?? TASK_METHODS.get, @@ -192,7 +204,7 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { // Keyed by tool name: the delivery endpoint only receives a task id, so it looks the handler up // from the name recorded on the task. - const handlers = new Map>(); + const handlers = new Map>(); const completedMessages = new Map(); const wired = new WeakSet(); @@ -200,9 +212,9 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { server: McpServer, name: string, config: TaskToolConfig, - handler: TaskHandler>, + handler: TaskHandler, TContext>, ): void { - handlers.set(name, handler as TaskHandler); + handlers.set(name, handler as TaskHandler); if (config.completedMessage) completedMessages.set(name, config.completedMessage); wireTaskMethods(server); @@ -288,7 +300,11 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { }); } - async function executeTask(taskId: string, steps?: TaskSteps): Promise { + async function executeTask( + taskId: string, + context?: TContext, + journal?: TaskJournal, + ): Promise { const task = await required(taskId); // The redelivery guard. Delivery is at-least-once by contract, so the same task id can arrive @@ -302,44 +318,42 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { ); } - const context: TaskContext = { + // Journaled writes get a stable name from their call order, which is deterministic because a + // replay re-runs the handler the same way up to the point it left off. + let writes = 0; + + const taskContext: TaskContext = { taskId, update: async (statusMessage) => { - await store.update(taskId, { statusMessage }); + const write = () => store.update(taskId, { statusMessage }).then(() => undefined); + // Without a journal this is a plain write that repeats on every replay — harmless on a + // queue, which never replays. + await (journal ? journal(`mcp-task:update:${++writes}`, write) : write()); }, isCancelled: async () => { const current = await store.get(taskId); // A task that expired out from under us is not worth finishing either. return current === null || current.status === "cancelled"; }, - // Without step support these are the plain, uncheckpointed equivalents, so the same handler - // runs under any dispatcher — it just cannot outlive one invocation. - run: steps ? steps.run : (_stepName, fn) => fn(), - sleep: steps - ? steps.sleep - : (_stepName, seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)), }; - try { - const result = await (handler as TaskHandler)(task.args, context); - // If a cancel landed while the handler was running, `settle` refuses the transition and - // returns null — the cancelled status wins, with no check-then-write race of our own. - const settled = await store.settle(taskId, { - status: "completed", - statusMessage: completedMessages.get(task.name) ?? "Completed", - result, - }); - return settled ?? (await store.get(taskId)); - } catch (cause) { - // Left non-terminal on purpose — see the note on ExecuteTaskOptions above. The task stays - // `working` so a redelivery can still finish it; the dispatcher settles it `failed` only - // once it stops trying. - const message = cause instanceof Error ? cause.message : String(cause); - await store - .update(taskId, { statusMessage: `Attempt failed: ${message}` }) - .catch(() => undefined); - throw cause; - } + // A throw propagates untouched, leaving the task non-terminal on purpose: the dispatcher + // decides whether that was a retry or a failure. Nothing is recorded here either, because the + // core cannot tell a real error from a workflow engine suspending the handler mid-step — and + // writing "attempt failed" for the latter would spray noise over a perfectly healthy run. + const result = await (handler as TaskHandler)( + task.args, + mergeContext(taskContext, context), + ); + + // If a cancel landed while the handler was running, `settle` refuses the transition and + // returns null — the cancelled status wins, with no check-then-write race of our own. + const settled = await store.settle(taskId, { + status: "completed", + statusMessage: completedMessages.get(task.name) ?? "Completed", + result, + }); + return settled ?? (await store.get(taskId)); } async function failTask(taskId: string, error: TaskError): Promise { @@ -355,13 +369,13 @@ export function createTaskLayer(options: TaskLayerOptions): TaskLayer { if (!handler) throw new Error(`No task handler registered for "${name}".`); // Running inline means there is no task and no transport, so none of the context can be more // than a no-op: nothing to report progress to, nothing to cancel, nothing to checkpoint. - return await (handler as TaskHandler)(args, { - taskId: "", - update: async () => undefined, - isCancelled: async () => false, - run: (_stepName, fn) => fn(), - sleep: (_stepName, seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)), - }); + return await (handler as TaskHandler)( + args, + mergeContext( + { taskId: "", update: async () => undefined, isCancelled: async () => false }, + undefined, + ), + ); } async function required(taskId: string): Promise { @@ -447,6 +461,23 @@ function clientSupportsTasks(context: unknown): boolean { return Boolean(capabilities?.extensions && TASKS_EXTENSION in capabilities.extensions); } +/** + * Merges the task context into whatever the transport supplied, as one object. + * + * The transport's context is *mutated* rather than copied, and deliberately: a `WorkflowContext` + * is a class instance whose `run`/`sleep`/`call` live on the prototype, so spreading it would drop + * every method, and re-parenting it with `Object.create` would break `this` for anything the + * engine keeps private. Assigning onto the instance keeps it intact — the object is ours for the + * duration of one invocation anyway. + */ +function mergeContext( + taskContext: TaskContext, + supplied: TContext | undefined, +): TaskContext & TContext { + if (supplied === undefined || supplied === null) return taskContext as TaskContext & TContext; + return Object.assign(supplied as object, taskContext) as TaskContext & TContext; +} + /** Strips the server-only fields, leaving exactly what the draft schema puts on the wire. */ export function toWire(task: Task): WireTask { const { name: _name, args: _args, dispatchId: _dispatchId, ...wire } = task; diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-tasks/src/index.ts index 3ef71cd..d9d08bb 100644 --- a/packages/mcp-tasks/src/index.ts +++ b/packages/mcp-tasks/src/index.ts @@ -27,7 +27,6 @@ export { type TaskError, type TaskPatch, type TaskEndpoints, - type TaskSteps, type TaskStatus, type TaskStore, type TerminalTaskPatch, @@ -35,7 +34,7 @@ export { type WireTask, } from "./types.js"; -export { InlineTaskDispatcher, MemoryTaskStore } from "./memory.js"; +export { InlineTaskDispatcher, MemoryTaskStore } from "./backends/memory.js"; export { SDK_TELEMETRY } from "./telemetry.js"; export { VERSION } from "./version.js"; diff --git a/packages/mcp-tasks/src/types.ts b/packages/mcp-tasks/src/types.ts index 0f49ad5..341d065 100644 --- a/packages/mcp-tasks/src/types.ts +++ b/packages/mcp-tasks/src/types.ts @@ -108,8 +108,11 @@ export interface TaskStore { * Applies a partial update without extending the task's original TTL — the retention window is * measured from creation, so a chatty progress handler must not keep a task alive forever. * - * Used for non-terminal writes (progress messages). Terminal transitions go through - * {@link settle} so they cannot race. + * **Ignored once the task is terminal**, and returns it unchanged. "Once a task reaches a + * terminal status its state does not change" covers the status message too, so a progress write + * that lands after a cancel must not overwrite "Cancelled by client". + * + * Used for non-terminal writes. Terminal transitions go through {@link settle}. */ update(taskId: string, patch: TaskPatch): Promise; @@ -130,8 +133,18 @@ export interface TaskStore { * A store keeps the record alive across a restart; only a dispatcher keeps the *work* alive. The * contract is deliberately at-least-once — that is what a queue can actually promise — so the * core guards against redelivery rather than assuming a message arrives exactly once. + * + * `TContext` is what this transport gives a running handler beyond the task itself, and it is the + * honest way to express that transports are not interchangeable. A queue delivery has nothing to + * offer, so `QStashDispatcher` is a `TaskDispatcher` and handlers take two arguments. A + * workflow engine has a great deal to offer, so `WorkflowDispatcher` is a + * `TaskDispatcher` and handlers take a third argument carrying the real + * engine API — steps, durable sleeps, `waitForEvent`, everything. + * + * Typing it this way rather than smoothing it into a lowest-common-denominator shim means the + * compiler tells you when a handler needs a transport that can actually run it. */ -export interface TaskDispatcher { +export interface TaskDispatcher { /** * Durably accepts an at-least-once delivery for a task before resolving, and returns a handle * that {@link cancel} understands. Return `undefined` when the transport has nothing to cancel. @@ -151,7 +164,7 @@ export interface TaskDispatcher { * failure once it has given up retrying — but the layer does not exist when the dispatcher is * constructed. This hands them over at wiring time instead of making callers late-bind. */ - attach?(endpoints: TaskEndpoints): void; + attach?(endpoints: TaskEndpoints): void; /** * Optionally, the transport's own delivery endpoint. @@ -168,12 +181,13 @@ export interface TaskDispatcher { } /** The layer's entry points, handed to a dispatcher by {@link TaskDispatcher.attach}. */ -export type TaskEndpoints = { +export type TaskEndpoints = { /** - * Runs a delivered task. Rejects if the handler threw — which the transport should treat as - * "deliver again", not as a failed task. + * Runs a delivered task, handing the handler whatever execution context this transport provides. + * Rejects if the handler threw — which the transport should treat as "deliver again", not as a + * failed task. */ - run(taskId: string, steps?: TaskSteps): Promise; + run(taskId: string, context: TContext, journal?: TaskJournal): Promise; /** * Records a terminal failure. Only the transport knows when retrying is over, so only the * transport calls this. @@ -182,44 +196,25 @@ export type TaskEndpoints = { }; /** - * Durable step primitives, when the transport has them. - * - * This is what separates a transport that can run work longer than one invocation from one that - * cannot. A queue delivery is a single function invocation: exceed its time limit and the work is - * killed, and a redelivery restarts the handler from the beginning. A workflow engine gives each - * step its own invocation and replays completed steps from a journal instead of re-running them. + * How a transport journals a side effect so it runs once across replays. * - * Handlers are written against {@link TaskContext.run} either way; supplying this is how a - * dispatcher upgrades those calls from plain function calls into durable checkpoints. + * Supplied by dispatchers whose engine re-enters the handler — the core uses it to wrap its own + * writes (`task.update`) so a progress message is not rewritten on every invocation. Handlers + * never see this; they get the engine's real API through the context instead. */ -export type TaskSteps = { - run(stepName: string, fn: () => Promise): Promise; - sleep(stepName: string, seconds: number): Promise; -}; +export type TaskJournal = (name: string, fn: () => Promise) => Promise; -/** What a task handler is handed alongside its arguments. */ +/** + * What every task handler is handed, whatever the transport. + * + * Anything transport-specific — a workflow's step and sleep primitives, say — arrives as the + * handler's third argument instead, typed by the dispatcher. See {@link TaskDispatcher}. + */ export type TaskContext = { /** The id of the running task. */ taskId: string; /** Publishes a human-readable progress line that the client's next poll will see. */ update(statusMessage: string): Promise; - /** - * Runs one step of the task, checkpointed when the dispatcher supports it. - * - * Under a workflow dispatcher each step runs in its own invocation and a completed step is - * replayed from the journal rather than re-executed, so the task as a whole can outlive any - * single function's time limit. Under a plain queue dispatcher this just calls `fn` — same - * result, no checkpoint — so a handler written with `run` works under both and gets more - * durability from the one that can provide it. - * - * `stepName` identifies the step in the journal and must be stable across replays. - */ - run(stepName: string, fn: () => Promise): Promise; - /** - * Waits, durably when the dispatcher supports it. A workflow sleep costs no compute and can - * span far longer than an invocation; without step support this is an ordinary timer. - */ - sleep(stepName: string, seconds: number): Promise; /** * Reads the durable status to see whether a client asked to stop. Cancellation is cooperative: * running code only stops where it checks, so call this at your step boundaries. diff --git a/packages/mcp-tasks/src/upstash.ts b/packages/mcp-tasks/src/upstash.ts index 3f79602..3731c26 100644 --- a/packages/mcp-tasks/src/upstash.ts +++ b/packages/mcp-tasks/src/upstash.ts @@ -1,480 +1,22 @@ /** - * The Upstash backends: a {@link TaskStore} on Upstash Redis and a {@link TaskDispatcher} on - * QStash. + * The Upstash backends, in one place: Redis for the task record, and either QStash or Upstash + * Workflow for the execution. * - * Nothing here imports the MCP SDK. That is the point of the two interfaces — a Postgres store or - * a BullMQ dispatcher drops in without the core noticing. - */ -import { Redis } from "@upstash/redis"; -import { Client as QStashClient, Receiver } from "@upstash/qstash"; - -/** JSON-RPC internal error, per the MCP spec — inlined so this file imports no MCP SDK. */ -const INTERNAL_ERROR = -32603; -import { - TERMINAL_STATUSES, - UnknownTaskError, - type Task, - type TaskDispatcher, - type TaskPatch, - type TaskEndpoints, - type TaskError, - type TaskStore, - type TerminalTaskPatch, -} from "./types.js"; -import { addTelemetry } from "./telemetry.js"; - -/** Default key prefix for task hashes: `mcp:task:`. */ -export const DEFAULT_TASK_PREFIX = "mcp:task:"; - -/** - * Backoff between delivery attempts: 1s, 3s, 9s, 27s, 81s — about two minutes across the default - * {@link DEFAULT_RETRIES} attempts, capped so a longer budget cannot drift into hours. - * - * The steep base is doing real work. What the retry budget has to outlast is whatever killed the - * process — a deploy, a crash loop, a cold start. When it does not, the record survives in Redis - * but nothing ever finishes the job, and the task sits at `working` until its TTL expires: exactly - * the failure durable execution exists to prevent. A flat one-second delay spends every attempt - * inside ten seconds, which no restart fits into. - */ -export const DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"; - -/** - * Delivery attempts before QStash dead-letters a task. - * - * **QStash caps this per plan** — the local dev server and the free tier reject anything above 5 - * with `quota maxRetries exceeded`, so 5 is the highest value that works everywhere and the budget - * is bought with {@link DEFAULT_RETRY_DELAY} instead. Raise it if your plan allows; Vercel's own - * QStash-backed Workflow world defaults to 47. - */ -export const DEFAULT_RETRIES = 5; - -export type RedisTaskStoreConfig = { - /** The Upstash Redis client. Defaults to `Redis.fromEnv()`. */ - redis?: Redis; - /** Key prefix for task hashes. Defaults to {@link DEFAULT_TASK_PREFIX}. */ - prefix?: string; - /** Set `false` to skip reporting the SDK version in the Redis telemetry header. */ - enableTelemetry?: boolean; -}; - -/** - * Every field is written JSON-encoded, and read back with no decoding of our own. - * - * That pairing is deliberate. `@upstash/redis` deserializes responses by default: it runs one - * `JSON.parse` over each value and falls back to the raw string. Writing `JSON.stringify(value)` - * makes that single parse the exact inverse of the write, so a status message of `"123"` returns - * as the string `"123"` and not the number `123` — which is what an unencoded write, or a second - * decode of our own, would produce. - */ -const encode = (value: unknown): string => JSON.stringify(value ?? null); - -/** - * The fields whose values are objects. They are the only ones worth repairing if a caller - * supplied a client built with `automaticDeserialization: false`, since a half-decoded scalar is - * indistinguishable from a legitimate string. - */ -const OBJECT_FIELDS: ReadonlySet = new Set(["args", "result", "error"]); - -/** - * Moves a task to a terminal state only if it is not terminal already, in one round trip. + * The two dispatchers are not interchangeable, and the type system says so — see + * {@link QStashDispatcher} and {@link WorkflowDispatcher} for which to pick. * - * `ARGV[1]` is how many terminal-status literals follow; the rest are field/value pairs. Returns - * 1 when this call performed the transition, 0 when the task was missing or already terminal. - */ -const SETTLE_SCRIPT = ` -local current = redis.call('HGET', KEYS[1], 'status') -if not current then return 0 end -local terminals = tonumber(ARGV[1]) -for i = 2, 1 + terminals do - if current == ARGV[i] then return 0 end -end -for i = 2 + terminals, #ARGV, 2 do - redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1]) -end -return 1 -`; - -const TERMINAL_LITERALS = [...TERMINAL_STATUSES].map(encode); - -/** - * A task record per Redis hash, with `EXPIRE` doing the TTL cleanup the draft asks for. - * - * One field per task property, rather than one JSON blob, so an update is a plain `HSET` of just - * the fields that changed. Two writers — a progress update and a client's cancel — therefore - * cannot clobber each other's fields, which a read-modify-write of a single blob would. - */ -export class RedisTaskStore implements TaskStore { - private readonly prefix: string; - private readonly enableTelemetry: boolean; - private readonly resolveRedis: () => Redis; - private client: Redis | undefined; - - constructor(config: RedisTaskStoreConfig | Redis = {}) { - // Accept a bare client too, so `new RedisTaskStore(redis)` reads naturally. - const options: RedisTaskStoreConfig = isRedisClient(config) ? { redis: config } : config; - this.prefix = options.prefix ?? DEFAULT_TASK_PREFIX; - this.enableTelemetry = options.enableTelemetry ?? true; - this.resolveRedis = () => options.redis ?? redisFromEnv(); - } - - /** - * The client, resolved on first use rather than in the constructor. - * - * Constructing must not need credentials: a store is typically created at module scope, and a - * framework evaluates those modules in places where the environment is not populated — a Next.js - * production build imports every route module to collect page data, so an eager `fromEnv()` fails - * the build of an app that would run fine in production. - */ - private get redis(): Redis { - if (!this.client) { - this.client = this.resolveRedis(); - addTelemetry(this.client, { enabled: this.enableTelemetry }); - } - return this.client; - } - - async create(task: Task): Promise { - const key = this.key(task.taskId); - await this.redis.hset(key, toFields(task)); - // The TTL is set once, at creation. Later updates use HSET, which never touches it, so the - // retention window is measured from creation no matter how chatty the handler is. - if (task.ttlMs !== null && task.ttlMs > 0) { - await this.redis.pexpire(key, task.ttlMs); - } - } - - async get(taskId: string): Promise { - const fields = await this.redis.hgetall>(this.key(taskId)); - if (!fields || Object.keys(fields).length === 0) return null; - return fromFields(fields); - } - - async update(taskId: string, patch: TaskPatch): Promise { - const fields = toFields({ ...patch, lastUpdatedAt: new Date().toISOString() }); - // HSET on a missing key would create a partial, TTL-less task, so check first. The check is - // not a lock: only `settle` needs atomicity, and it has it. - const exists = await this.redis.exists(this.key(taskId)); - if (!exists) throw new UnknownTaskError(taskId); - await this.redis.hset(this.key(taskId), fields); - const task = await this.get(taskId); - if (!task) throw new UnknownTaskError(taskId); - return task; - } - - async settle(taskId: string, patch: TerminalTaskPatch): Promise { - const fields = toFields({ ...patch, lastUpdatedAt: new Date().toISOString() }); - const args: string[] = [String(TERMINAL_LITERALS.length), ...TERMINAL_LITERALS]; - for (const [field, value] of Object.entries(fields)) args.push(field, value); - - const applied = await this.redis.eval( - SETTLE_SCRIPT, - [this.key(taskId)], - args, - ); - if (applied !== 1) return null; - return await this.get(taskId); - } - - /** The Redis key a task is stored under. */ - key(taskId: string): string { - return this.prefix + taskId; - } -} - -export type QStashDispatcherConfig = { - /** The QStash client. Defaults to `new Client({ token: QSTASH_TOKEN, baseUrl: QSTASH_URL })`. */ - qstash?: QStashClient; - /** - * The absolute, publicly reachable URL QStash delivers a task to. Your handler there reads - * `{ taskId }` from the body and calls `executeTask(taskId)`. - */ - url: string; - /** - * Delivery attempts before QStash gives up and dead-letters the message. Defaults to - * {@link DEFAULT_RETRIES}. - */ - retries?: number; - /** - * Backoff between attempts, as a QStash delay expression. Defaults to exponential — - * {@link DEFAULT_RETRY_DELAY}. - * - * The retry budget is what has to outlast a restart, and it is easy to get wrong: a flat - * `"1000"` with a handful of retries burns every attempt within seconds, so a process killed - * mid-task exhausts its redeliveries before it is back up and the task is dead-lettered while - * still reading `working`. Size the budget against how long your deploys actually take. - */ - retryDelay?: string; - /** Extra headers to send with the delivery. */ - headers?: Record; - /** - * Verifies the signature on incoming deliveries in {@link QStashDispatcher.createExecuteHandler}. - * Defaults to a `Receiver` built from `QSTASH_CURRENT_SIGNING_KEY` / `QSTASH_NEXT_SIGNING_KEY`. - */ - receiver?: Receiver; -}; - -/** - * Publishes each task to QStash, which stores the message durably before delivery and retries a - * failing endpoint. That is the half Redis cannot do: if the process that accepted the tool call - * dies mid-run, the record survives in Redis but only a redelivery finishes the work. - */ -export class QStashDispatcher implements TaskDispatcher { - private readonly url: string; - private readonly retries: number; - private readonly retryDelay: string; - private readonly headers: Record | undefined; - private readonly resolveQStash: () => QStashClient; - private client: QStashClient | undefined; - private readonly resolveReceiver: () => Receiver; - private verifier: Receiver | undefined; - private endpoints: TaskEndpoints | undefined; - - constructor(config: QStashDispatcherConfig) { - this.url = config.url; - this.retries = config.retries ?? DEFAULT_RETRIES; - this.retryDelay = config.retryDelay ?? DEFAULT_RETRY_DELAY; - this.headers = config.headers; - this.resolveQStash = () => config.qstash ?? qstashFromEnv(); - this.resolveReceiver = () => config.receiver ?? receiverFromEnv(); - } - - attach(endpoints: TaskEndpoints): void { - this.endpoints = endpoints; - } - - /** Resolved on first use, for the same reason as {@link RedisTaskStore}'s client. */ - private get qstash(): QStashClient { - if (!this.client) this.client = this.resolveQStash(); - return this.client; - } - - private get receiver(): Receiver { - if (!this.verifier) this.verifier = this.resolveReceiver(); - return this.verifier; - } - - async dispatch(taskId: string): Promise { - const message = await this.qstash.publishJSON({ - url: this.url, - body: { taskId }, - retries: this.retries, - retryDelay: this.retryDelay, - headers: this.headers, - // The failure callback comes back to this same endpoint. QStash signs it against the URL it - // posts to, so one URL means one signature check and one route for the application. - failureCallback: this.url, - // QStash delivery is at-least-once. Pinning deduplication to the task id means a - // double-submitted tool call cannot enqueue the same task twice. - deduplicationId: taskId, - }); - return Array.isArray(message) ? message[0]?.messageId : message.messageId; - } - - async cancel(dispatchId: string): Promise { - await this.qstash.messages.cancel(dispatchId); - } - - /** - * The delivery endpoint, as a fetch handler: `export const POST = tasks.createExecuteHandler()`. - * - * One route serves both things QStash sends here, told apart by the body: - * - * - a **delivery** (`{ taskId }`) — run the task; - * - a **failure callback**, which carries `sourceBody` and fires only once every retry is - * exhausted — settle the task `failed`. - * - * That second half is why nothing in this package counts attempts. QStash already knows when it - * has given up; asking it rather than re-deriving it from a retry header means the answer cannot - * drift from the configuration. - * - * Status codes are the retry contract: - * - **200** — the task ran, was already terminal, or the failure was recorded. Done. - * - **401** — the signature did not verify. Deliberately terminal: a retry cannot fix a bad - * signature, and answering 500 would make QStash replay an unauthenticated request. - * - **400** — the body was neither a delivery nor a failure callback. Also terminal. - * - **500** — the handler threw. This is the one that asks for a redelivery. - */ - createExecuteHandler(): (request: Request) => Promise { - return async (request: Request): Promise => { - const endpoints = this.endpoints; - if (!endpoints) { - throw new Error( - "This dispatcher is not attached to a task layer — pass it to createTaskLayer().", - ); - } - - const body = await request.text(); - - try { - // Verified against the URL we published to, not `request.url`: behind a proxy the - // incoming URL is the internal one, while QStash signed the public destination. - await this.receiver.verify({ - signature: request.headers.get("upstash-signature") ?? "", - body, - url: this.url, - }); - } catch { - return new Response("invalid signature", { status: 401 }); - } - - let payload: QStashDelivery; - try { - payload = JSON.parse(body) as QStashDelivery; - } catch { - return new Response("malformed body", { status: 400 }); - } - - const failure = readFailureCallback(payload); - if (failure) { - await endpoints.fail(failure.taskId, failure.error); - // 200: the failure is recorded. A non-2xx here would only make QStash retry the callback. - return new Response("recorded"); - } - - if (!payload.taskId) return new Response("missing taskId", { status: 400 }); - - try { - await endpoints.run(payload.taskId); - return new Response("ok"); - } catch { - // The task is left `working` on purpose; the non-2xx is purely how you ask QStash for - // another delivery. If it runs out, the failure callback above settles the task. - return new Response("retry", { status: 500 }); - } - }; - } -} - -/** Either shape QStash posts to the execute endpoint. */ -type QStashDelivery = { - /** Present on a normal delivery: the body we published. */ - taskId?: string; - /** Present on a failure callback: base64 of the body of the message that failed. */ - sourceBody?: string; - /** The failed response's status. */ - status?: number; - /** Base64 of the failed response's body. */ - body?: string; - /** The dead-letter entry the message landed in, so an operator can find and replay it. */ - dlqId?: string; - retried?: number; - maxRetries?: number; -}; - -/** - * Recognises a failure callback and turns it into the error the task will carry. - * - * `sourceBody` is the discriminator: a normal delivery is the `{ taskId }` we published and has no - * such field, while the callback wraps it. Both are decoded from base64 per QStash's contract. - */ -function readFailureCallback( - payload: QStashDelivery, -): { taskId: string; error: TaskError } | undefined { - if (typeof payload.sourceBody !== "string") return undefined; - - let taskId: string | undefined; - try { - taskId = (JSON.parse(decodeBase64(payload.sourceBody)) as { taskId?: string }).taskId; - } catch { - return undefined; - } - if (!taskId) return undefined; - - const responseBody = typeof payload.body === "string" ? decodeBase64(payload.body) : undefined; - return { - taskId, - error: { - code: INTERNAL_ERROR, - message: `Delivery failed after ${payload.retried ?? payload.maxRetries ?? "all"} retries${ - payload.status ? ` (last status ${payload.status})` : "" - }`, - // Keep what an operator needs to find the message again and see what the endpoint said. - data: { dlqId: payload.dlqId, status: payload.status, response: responseBody }, - }, - }; -} - -/** - * Decodes QStash's base64 fields, through whichever primitive the runtime has. Both globals are - * reached via `globalThis` so this file stays free of runtime-specific globals — it has to work on - * Node, edge and worker runtimes alike. - */ -type Base64Global = { - atob?: (value: string) => string; - Buffer?: { from(value: string, encoding: string): { toString(encoding: string): string } }; -}; - -function decodeBase64(value: string): string { - const runtime = globalThis as unknown as Base64Global; - if (runtime.atob) return runtime.atob(value); - if (runtime.Buffer) return runtime.Buffer.from(value, "base64").toString("utf8"); - throw new Error("No base64 decoder available in this runtime."); -} - -/** Encodes a partial task into the hash fields that represent it. `undefined` values are skipped. */ -function toFields(patch: Partial): Record { - const fields: Record = {}; - for (const [field, value] of Object.entries(patch)) { - if (value === undefined) continue; - fields[field] = encode(value); - } - return fields; -} - -/** - * Turns stored hash fields back into a task. The client's own deserialization has already undone - * {@link encode}, so this only skips absent fields and repairs objects that arrived as strings. - */ -function fromFields(fields: Record): Task { - const task: Record = {}; - for (const [field, raw] of Object.entries(fields)) { - if (raw === undefined) continue; - if (typeof raw === "string" && OBJECT_FIELDS.has(field)) { - try { - task[field] = JSON.parse(raw); - continue; - } catch { - // Not JSON after all — fall through and keep the raw value. - } - } - // `ttlMs: null` is meaningful ("unlimited"), so nulls are kept rather than dropped. - task[field] = raw; - } - if (!("ttlMs" in task)) task.ttlMs = null; - return task as Task; -} - -function isRedisClient(value: RedisTaskStoreConfig | Redis): value is Redis { - return typeof (value as Redis).hgetall === "function"; -} - -function redisFromEnv(): Redis { - const { UPSTASH_REDIS_REST_URL: url, UPSTASH_REDIS_REST_TOKEN: token } = process.env; - if (!url || !token) { - throw new Error( - "RedisTaskStore needs a client: pass `redis`, or set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.", - ); - } - return new Redis({ url, token }); -} - -function receiverFromEnv(): Receiver { - const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY; - const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY; - if (!currentSigningKey || !nextSigningKey) { - throw new Error( - "createExecuteHandler needs signing keys: pass `receiver`, or set QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY.", - ); - } - return new Receiver({ currentSigningKey, nextSigningKey }); -} - -function qstashFromEnv(): QStashClient { - const token = process.env.QSTASH_TOKEN; - if (!token) { - throw new Error("QStashDispatcher needs a client: pass `qstash`, or set QSTASH_TOKEN."); - } - // QSTASH_URL points the client at the local dev server when one is running; the hosted URL is - // the client's own default. - return new QStashClient({ token, baseUrl: process.env.QSTASH_URL }); -} + * This is the only Upstash entry point, so it pulls in `@upstash/redis`, `@upstash/qstash` and + * `@upstash/workflow`. All three are optional peers of the package, but an app importing from + * here needs them installed. + */ +export { + RedisTaskStore, + QStashDispatcher, + DEFAULT_RETRIES, + DEFAULT_RETRY_DELAY, + DEFAULT_TASK_PREFIX, + type RedisTaskStoreConfig, + type QStashDispatcherConfig, +} from "./backends/qstash.js"; + +export { WorkflowDispatcher, type WorkflowDispatcherConfig } from "./backends/workflow.js"; diff --git a/packages/mcp-tasks/tsup.config.ts b/packages/mcp-tasks/tsup.config.ts index 9900efb..4d4a102 100644 --- a/packages/mcp-tasks/tsup.config.ts +++ b/packages/mcp-tasks/tsup.config.ts @@ -4,7 +4,6 @@ export default defineConfig({ entry: { index: "src/index.ts", upstash: "src/upstash.ts", - workflow: "src/workflow.ts", }, format: ["esm"], dts: true, From b43c405325afb71e756ee8a09eb404398d412481 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 17:09:22 +0300 Subject: [PATCH 06/11] docs(mcp-tasks): restructure the README, and note mcp-handler compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/hot-jars-judge.md | 2 +- packages/mcp-tasks/README.md | 503 +++++++++++++++------- packages/mcp-tasks/src/backends/qstash.ts | 3 +- 3 files changed, 338 insertions(+), 170 deletions(-) diff --git a/.changeset/hot-jars-judge.md b/.changeset/hot-jars-judge.md index 3fac551..c9c40e2 100644 --- a/.changeset/hot-jars-judge.md +++ b/.changeset/hot-jars-judge.md @@ -10,7 +10,7 @@ restart. 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`. Modelled on Vercel Workflow's `Queue.createQueueHandler`. +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 diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 05d2d21..8a961d0 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -1,47 +1,14 @@ # @upstash/mcp-tasks A durable [MCP Tasks](https://github.com/modelcontextprotocol/ext-tasks) runtime for the official -TypeScript SDK, with Upstash Redis and QStash as the backends. +TypeScript SDK. -The 2026-07-28 MCP spec made the protocol stateless: no `initialize` handshake, no `Mcp-Session-Id`, -every request carrying its own protocol version, client identity and capabilities in `_meta`. Long -running tools got the Tasks extension — a tool call answers with a task handle and the client polls -for the result. The official TypeScript SDK v2 ships the wire schemas for it but **no tasks -runtime**; the v1 experimental task APIs were removed with no migration path. +A long-running tool answers with a task handle instead of blocking. The task record lives in +Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that +accepted the call. -This package is that runtime. It is one factory over two interfaces, so the storage and the -execution transport are yours to choose: - -| Layer | Interface | What it has to guarantee | What ships here | -| --- | --- | --- | --- | -| Task record | `TaskStore` | Durable create before the response, TTL cleanup | Upstash Redis hash + `PEXPIRE` | -| Execution | `TaskDispatcher` | At-least-once delivery that survives a dead process, cancellable while pending | QStash, or Upstash Workflow | -| Polling | — | `tasks/get` reads the store | built in | - -### Which dispatcher - -Both serve the same route and run the same handler. They differ in one thing — how long the work -is allowed to take. - -| | `QStashDispatcher` | `WorkflowDispatcher` | -| --- | --- | --- | -| Runs the work off the `tools/call` request | ✅ | ✅ | -| Survives the process dying | ✅ redelivery | ✅ replay | -| Can outlive one function invocation | ❌ | ✅ one invocation per step | -| Retries | whole task, from the start | per step, resuming from the journal | - -A queue delivery is a single serverless invocation. Exceed your platform's limit and the work is -killed, and the redelivery restarts your handler from the beginning — for a task measured in -minutes or hours that is a livelock, not durability. Workflow gives each `task.run(...)` step its -own invocation and replays finished steps from a journal, so the task as a whole has no time -limit. Start on QStash; move to Workflow when the work outgrows a function. - -## Why two interfaces and not one - -A durable task ID does not make the underlying work durable. Write the record to shared storage and -then run the work in a fire-and-forget promise, and a deploy mid-task leaves you with a perfectly -durable record of a task stuck in `working` until its TTL expires. The record and the work are -separate problems, so they get separate seams. +> The official SDK v2 ships the 2026-07-28 wire schemas for tasks but no runtime behind them — the +> v1 experimental task APIs were removed with no migration path. This is that runtime. ## Install @@ -49,50 +16,32 @@ separate problems, so they get separate seams. npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash ``` -`@upstash/redis` and `@upstash/qstash` are only needed for the Upstash backends, which live behind -the `@upstash/mcp-tasks/upstash` entry point. Bring your own store and the root import pulls -neither. - ## Usage ```ts -import { McpServer, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { McpServer } from "@modelcontextprotocol/server"; import { createTaskLayer, TASKS_PROTOCOL_VERSION } from "@upstash/mcp-tasks"; import { QStashDispatcher, RedisTaskStore } from "@upstash/mcp-tasks/upstash"; import * as z from "zod"; -const tasks = createTaskLayer({ - store: new RedisTaskStore(), // optional: { redis, prefix, enableTelemetry } - dispatcher: new QStashDispatcher({ - url: `${process.env.APP_URL}/api/execute`, // where QStash delivers the task - // retries / retryDelay default to a budget that outlives a restart — see below - }), - defaults: { ttlMs: 300_000, pollIntervalMs: 2_000 }, // optional +export const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), }); export function createServer() { const server = new McpServer( { name: "reports", version: "1.0.0" }, - // Required: the transport otherwise rejects 2026-07-28 requests as an unsupported version. { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, ); tasks.registerTask( server, "generate_report", - { - description: "Generates a report in four durable steps", - inputSchema: z.object({ topic: z.string() }), - ttlMs: 300_000, // optional: retention, null for unlimited - pollIntervalMs: 2_000, // optional: what to suggest to the client - }, + { description: "Generates a report", inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, task) => { - for (let step = 1; step <= 4; step++) { - if (await task.isCancelled()) return {}; - await task.update(`Step ${step}/4: processing ${topic}`); - await doWork(topic, step); - } - return { content: [{ type: "text", text: `Report complete: ${topic}` }] }; + await task.update(`Researching ${topic}`); + return { content: [{ type: "text", text: `Report on ${topic}` }] }; }, ); @@ -100,157 +49,377 @@ export function createServer() { } ``` -Then two endpoints — the MCP transport, and the one QStash delivers to: +Then two routes — the MCP endpoint, and the one the work is delivered to: ```ts -// POST /api/mcp -const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, -}); -await createServer().connect(transport); -return transport.handleRequest(request); +// app/api/mcp/route.ts +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { createServer } from "../../lib/tasks"; + +export async function POST(request: Request) { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + await createServer().connect(transport); + return transport.handleRequest(request); +} ``` ```ts // app/api/execute/route.ts +import { tasks } from "../../lib/tasks"; + export const POST = tasks.createExecuteHandler(); ``` -That second one is deliberately not yours to write. Verifying the signature, reading the task id, -recognising a failure callback, and picking the status code that decides whether the transport -tries again are all facts about the transport, and the dispatcher already knows them — so it hands -you the endpoint instead of a checklist. Skipping the signature check would let anyone who can -reach the route run tasks; here you cannot skip it. +That second route is deliberately not yours to write — the dispatcher owns it. See the +[FAQ](#faq) for what it does. + +## Choosing a dispatcher -Switching transports is the dispatcher line and nothing else — same route, same handler: +Both serve the same route. They differ in how long the work may take. + +| | `QStashDispatcher` | `WorkflowDispatcher` | +| --- | --- | --- | +| Runs off the `tools/call` request | ✅ | ✅ | +| Survives the process dying | ✅ redelivery | ✅ replay | +| Outlives one function invocation | ❌ | ✅ one invocation per step | +| Retries | whole task, from the start | per step, resuming from the journal | + +A queue delivery is a single serverless invocation: exceed your platform's function limit and the +work is killed, and the redelivery restarts your handler from the beginning. Workflow gives each +step its own invocation and replays finished ones from a journal, so the task has no time limit. + +**Start on QStash. Move to Workflow when the work outgrows a function.** ```ts -import { WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import type { WorkflowContext } from "@upstash/workflow"; -dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new WorkflowDispatcher({ url: `${process.env.APP_URL}/api/execute` }), +}); ``` +The type argument flows into `registerTask`, so the handler's context becomes +`TaskContext & WorkflowContext` — `task.update(...)` and the engine's `task.run(...)` on one object: + +```ts +async ({ topic }, task) => { + const data = await task.run("fetch", () => fetchSources(topic)); + await task.sleep("cool-off", 5); + return { content: [{ type: "text", text: await task.run("write", () => summarise(data)) }] }; +}; +``` + +
+Writing a workflow handler: what goes inside a step + +The handler is re-entered once per step, with finished steps replayed from the journal. So code +*outside* a step runs again on every invocation. Measured on the demo: **19 handler entries, each +step body executed exactly once.** + +- **Work goes inside `task.run`.** That is what makes it survive, and what stops it re-running. +- **`task.update(...)` needs no wrapping.** The SDK journals its own writes. +- **`task.isCancelled()` stays outside.** It is a read, and it *must* re-run — a cached `false` + would mean a cancel arriving later is never noticed. +- **Never nest steps.** The engine rejects `task.run` inside `task.run`. + +
+ ## What the client sees ```jsonc // tools/call → a handle, immediately -{ "resultType": "task", "taskId": "0e30…", "status": "working", - "statusMessage": "Queued for durable execution", "ttlMs": 300000, "pollIntervalMs": 2000 } +{ "resultType": "task", "taskId": "0e30…", "status": "working", "ttlMs": 300000, "pollIntervalMs": 2000 } // tasks/get → progress, then the result inline -{ "resultType": "complete", "taskId": "0e30…", "status": "working", "statusMessage": "Step 3/4: …" } -{ "resultType": "complete", "taskId": "0e30…", "status": "completed", "statusMessage": "Completed", - "result": { "content": [{ "type": "text", "text": "Report complete: coffee trends" }] } } +{ "resultType": "complete", "taskId": "0e30…", "status": "working", "statusMessage": "Researching coffee" } +{ "resultType": "complete", "taskId": "0e30…", "status": "completed", + "result": { "content": [{ "type": "text", "text": "Report on coffee" }] } } ``` Five states — `working`, `input_required`, `completed`, `failed`, `cancelled` — of which the last three are terminal and never change again. -## Design notes - -Five things here are deliberate, and most of them differ from the obvious implementation. - -**The record is written before the handle goes out.** The spec requires it: the client may -`tasks/get` the id against another instance the moment it has it. So `registerTask` creates, then -dispatches, then responds — never the other way around. - -**Terminal transitions go through `settle`, not `update`.** Two writers race for the end of a task -by design — a client's `tasks/cancel` and the executor finishing at the same moment. `settle` moves -a task to a terminal state *only if it is not terminal already*, atomically (a Lua script on Redis), -and returns `null` when it lost. A check-then-write would let a late `completed` overwrite a -`cancelled`; this cannot. The store also keeps one field per task property rather than one JSON -blob, so a progress update and a cancel never clobber each other's fields. - -**A failed attempt is not automatically a failed task, and the core never decides which is which.** -Settling `failed` on the first error makes the task terminal, so every later redelivery -short-circuits on the redelivery guard and the retries are silently useless. But knowing that a -failure is *final* means knowing whether the transport will try again — and only the transport -knows that. So `executeTask` rethrows and leaves the task `working`; the dispatcher calls -`failTask` once it has genuinely given up. QStash learns this from its own failure callback, which -fires only after every retry is exhausted; Workflow from its `failureFunction`. Nothing in this -package counts attempts or reads a retry header. - -**Under a step-capable dispatcher, only `task.run` bodies are replay-safe.** Workflow re-enters the -handler once per step and replays finished steps from the journal, so anything *outside* a step -runs again on every invocation — measured on the demo: 19 handler entries, each step body executed -exactly once. Put side effects (including `task.update`) inside `task.run`; leave reads like -`task.isCancelled()` outside, where re-running them is the point. - -**Redelivery is expected, not exceptional.** At-least-once is the strongest thing a queue promises, -so `executeTask` returns early on an already-terminal task. - -**The retry budget has to outlast a restart.** This is the one default most likely to bite you. A -task is only as durable as the number of redeliveries left when the process died — run out, and the -record survives in Redis while nothing ever finishes the work, leaving `working` until the TTL -expires. QStash caps `retries` per plan (the local dev server and the free tier reject anything -above **5** with `quota maxRetries exceeded`), so the budget is bought with backoff instead: the -default delay is `min(pow(3, retried) * 1000, 300000)` — 1s, 3s, 9s, 27s, 81s, about two minutes -across five attempts. Raise `retries` if your plan allows; for comparison, Vercel's QStash-backed -Workflow world defaults to 47. - -## Two gotchas in the official SDK - -Both verified against `@modelcontextprotocol/server@2.0.0`, and both are why this package exists in -the shape it does. - -**`createMcpHandler` cannot serve `tasks/get` / `tasks/cancel`.** It pins each request to the -2026-07-28 era from the client's envelope claim, and on that era the SDK's dispatch gate answers -those two methods with `-32601` *before* looking up your handler — they are claimed spec vocabulary -in its 2025 registry and were dropped from the 2026 one, so they are neither dispatchable nor -free-form. Either serve with `WebStandardStreamableHTTPServerTransport` (or the Node one) and -`transport.handleRequest`, which stays on the 2025 era where they dispatch normally — the per-request -`_meta` envelope is still lifted, so nothing else changes — or keep `createMcpHandler` and move the -operations to your own namespace: +## How it fits together -```ts -createTaskLayer({ store, dispatcher, methods: { get: "upstash/tasks.get", cancel: "upstash/tasks.cancel" } }); +
+Who is responsible for what + +| | Owns | +| --- | --- | +| **`@upstash/mcp-tasks`** | The protocol: creating the record before replying, serving `tasks/get` / `tasks/cancel`, the capability check, the redelivery guard, settling `completed`/`cancelled` | +| **`TaskStore`** | Durability of the *record*: create-before-response, TTL, and the atomic terminal transition so a cancel and a completion cannot clobber each other | +| **`TaskDispatcher`** | Durability of the *work*: delivering it, retrying it, cancelling a pending delivery, authenticating its own endpoint, and deciding when a failure is final | +| **Your handler** | The work, and checking `isCancelled()` at step boundaries | + +The split is the whole design: a durable task id does not make the underlying work durable. + +
+ +
+Flow: tools/call → a task handle + +```mermaid +sequenceDiagram + participant C as Client + participant S as mcp-tasks + participant St as TaskStore + participant D as TaskDispatcher + + C->>S: tools/call (declares tasks capability) + S->>S: capability present? else structured tool error (-32021) + S->>St: create(task) + Note over St: must commit before the reply —
a tasks/get may hit another instance + St-->>S: ok + S->>D: dispatch(taskId) + D-->>S: dispatchId + S->>St: update({ dispatchId }) + S-->>C: resultType "task" + handle ``` -**A tool callback cannot return a JSON-RPC error.** `McpServer` catches everything a tool callback -throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and flattens it into -`{ content, isError: true }`, dropping the code. So a client that has not declared the tasks -capability gets a structured tool error instead, with the code and the capability it is missing in -`structuredContent`: +Order is the spec's, not a preference: the record must be durable before the handle goes out. + +
+ +
+Flow: the work running + +```mermaid +sequenceDiagram + participant D as Dispatcher (QStash/Workflow) + participant E as /api/execute + participant S as mcp-tasks + participant H as Your handler + participant St as TaskStore + + D->>E: deliver { taskId } (signed) + E->>E: verify signature — 401 if bad + E->>S: executeTask(taskId) + S->>St: get(taskId) + S->>S: already terminal? → stop (redelivery guard) + S->>H: run(args, task) + H->>St: update(statusMessage) via task.update + H-->>S: result + S->>St: settle(completed, result) + E-->>D: 200 +``` -```jsonc -{ "isError": true, - "content": [{ "type": "text", "text": "\"generate_report\" answers with a task handle, which requires …" }], - "structuredContent": { "code": -32021, - "requiredCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } } } +If the handler throws, nothing is recorded and the endpoint answers **500** — that asks the +transport for another delivery. Only the transport settles `failed`, and only once it has stopped +retrying. + +
+ +
+Flow: tasks/get and tasks/cancel + +```mermaid +sequenceDiagram + participant C as Client + participant S as mcp-tasks + participant St as TaskStore + participant D as TaskDispatcher + + C->>S: tasks/get { taskId } + S->>St: get(taskId) + St-->>S: task (or null → -32602) + S-->>C: resultType "complete" + public fields + + C->>S: tasks/cancel { taskId } + S->>St: settle(cancelled) + Note over St: refused if already terminal —
first terminal write wins + S->>D: cancel(dispatchId) + S-->>C: the cancelled task +``` + +`tasks/get` is a pure read — nothing about it advances the work. Cancellation is cooperative: the +store flips the status, the dispatcher stops a pending delivery, and the handler stops where it +checks. + +
+ +## Reference + +
+The two interfaces + +```ts +interface TaskStore { + create(task: Task): Promise; + get(taskId: string): Promise; + /** Ignored once the task is terminal — a late write must not overwrite "Cancelled by client". */ + update(taskId: string, patch: TaskPatch): Promise; + /** Atomic. Returns null when the task was already terminal, so first terminal write wins. */ + settle(taskId: string, patch: TerminalTaskPatch): Promise; +} + +interface TaskDispatcher { + dispatch(taskId: string): Promise; + cancel(dispatchId: string): Promise; + attach?(endpoints: TaskEndpoints): void; + createExecuteHandler?(): (request: Request) => Promise; +} ``` -Pass `onMissingCapability: "run-inline"` to run the handler and answer normally instead — spec-legal, -since the server chooses per call, but it brings back the blocking request tasks exist to avoid. +Implement both and the core does not change. A Postgres store is the same four methods over one +table with a cleanup job standing in for `PEXPIRE`; a BullMQ dispatcher is an `add` returning the +job id and a `remove` for cancel. `MemoryTaskStore` + `InlineTaskDispatcher` ship for tests — +neither is durable, which is exactly the failure this package is about. + +
+ +
+Options -## Bringing your own backend +**`createTaskLayer`** -Implement `TaskStore` (four methods) and `TaskDispatcher` (two), and the core does not change. A -Postgres store is the same methods over one table with a cleanup job standing in for `PEXPIRE`; a -BullMQ dispatcher is an `add` returning the job id and a `remove` for cancel. -`MemoryTaskStore` + `InlineTaskDispatcher` ship for tests and local runs — neither is durable, which -is exactly the failure this package is about. +| | | +| --- | --- | +| `store`, `dispatcher` | Required. | +| `defaults.ttlMs` | Retention window, `null` for unlimited. Default 5 min. | +| `defaults.pollIntervalMs` | Poll interval to suggest to clients. Default 2s. | +| `onMissingCapability` | `"error"` (default) or `"run-inline"` — run the handler and answer normally for a client that cannot poll. | +| `methods` | Rename the task methods. Needed only with `createMcpHandler`; see the FAQ. | + +**`registerTask` config** — `description`, `inputSchema`, plus optional `title`, `ttlMs`, +`pollIntervalMs`, `queuedMessage`, `completedMessage`. + +**`RedisTaskStore`** — `redis` (defaults to `Redis.fromEnv()`), `prefix`, `enableTelemetry`. + +**`QStashDispatcher`** — `url` required; `qstash`, `receiver`, `retries`, `retryDelay`, `headers`. -## API +**`WorkflowDispatcher`** — `url` required; `client`, `headers`, `retries`. + +
+ +
+Exports | Export | What it is | | --- | --- | -| `createTaskLayer(options)` | The runtime: `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, store, dispatcher }` | +| `createTaskLayer(options)` | `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, store, dispatcher }` | | `TaskStore`, `TaskDispatcher`, `TaskContext` | The two seams, and what a handler is handed | -| `TaskEndpoints`, `TaskSteps` | What a dispatcher is given to call back into, and the step primitives it may provide | +| `TaskEndpoints`, `TaskJournal` | What a dispatcher calls back into, and how it journals the SDK's writes | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset that goes on the wire | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `TASKS_EXTENSION`, `TASKS_PROTOCOL_VERSION`, `TASK_METHODS` | The extension id, `"2026-07-28"`, the method names | | `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | -| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher`, `DEFAULT_RETRIES`, `DEFAULT_RETRY_DELAY` | +| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher` | + +
+ +## FAQ + +
+What does the execute endpoint actually do? + +Everything that has to be right there belongs to the transport, which is why the dispatcher hands +you the endpoint instead of a checklist: + +- **Verifies the signature.** Against the URL you published to, not `request.url` — behind a proxy + the incoming URL is the internal one while QStash signed the public destination. Skipping this + would let anyone who can reach the route run tasks; here you cannot skip it. +- **Tells a delivery from a failure callback.** Both arrive at this one route; the failure callback + carries `sourceBody` and fires only once every retry is exhausted. +- **Picks the status code**, which is the retry contract: **200** ran or already terminal, **500** + the handler threw and the transport should try again, **401** bad signature and **400** an + unusable body — both terminal, because a retry cannot fix either. + +
+ +
+Does it work with mcp-handler? + +Yes, with one line of config. [`mcp-handler`](https://www.npmjs.com/package/mcp-handler) wraps the +SDK's own `createMcpHandler`, which serves the 2026-07-28 era — and on that era `tasks/get` and +`tasks/cancel` are answered with **-32601 before your handler is looked up**. Rename them and +everything dispatches: + +```ts +import { createMcpHandler } from "mcp-handler"; + +const tasks = createTaskLayer({ + store: new RedisTaskStore(), + dispatcher: new QStashDispatcher({ url: `${process.env.APP_URL}/api/execute` }), + methods: { get: "upstash/tasks.get", cancel: "upstash/tasks.cancel" }, +}); + +export const POST = createMcpHandler((server) => { + tasks.registerTask(server, "generate_report", { /* … */ }, handler); +}); +``` + +Task *creation* needs no change — `tools/call` returns `resultType: "task"` through `mcp-handler` +as-is. Only the two task methods move, and the cost is that they are no longer the spec's wire +names, so a client has to know yours. + +
+ +
+Why the transport instead of the SDK's createMcpHandler? + +Same reason. `tasks/get` and `tasks/cancel` sit in the SDK's **2025** method registry and were +dropped from the **2026** one, so on the modern era they are neither dispatchable nor treated as +free-form extension methods — the gate returns `-32601` before your handler runs. Serving through +`WebStandardStreamableHTTPServerTransport` leaves the instance on the 2025 era, where they dispatch +normally and the per-request `_meta` envelope is still lifted, so nothing else changes. + +Verified against the real SDK: the registered handler never runs on `createMcpHandler`, while a +namespaced method on the same server dispatches fine. + +
+ +
+Why does a missing capability come back as a tool error, not -32021? + +Because a tool callback cannot return a JSON-RPC error. `McpServer` catches everything a tool +callback throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and +flattens it into `{ content, isError: true }`, dropping the code. So the code and the capability +you are missing are put where a client can actually read them: + +```jsonc +{ "isError": true, + "content": [{ "type": "text", "text": "\"generate_report\" answers with a task handle, which requires …" }], + "structuredContent": { "code": -32021, + "requiredCapabilities": { "extensions": { "io.modelcontextprotocol/tasks": {} } } } } +``` + +
+ +
+How long do retries last, and what if they run out? + +The retry budget has to outlast whatever killed the process — otherwise the record survives while +nothing finishes the work, and the task sits at `working` until its TTL. + +QStash caps `retries` per plan: the local dev server and the free tier reject anything above **5** +with `quota maxRetries exceeded`. So the budget is bought with backoff instead — the default delay +is `min(pow(3, retried) * 1000, 300000)`, about two minutes across five attempts. + +When they do run out, QStash calls its failure callback and the task settles `failed` with the DLQ +id and the failed response attached. The message is in the QStash DLQ, not lost. + +
+ +
+Is the task id a secret? + +Effectively, yes. Ids are `randomUUID` (~122 bits), and the spec permits treating them as bearer +tokens. But `tasks/get` and `tasks/cancel` resolve by id alone, so anyone who learns one can read +*and cancel* that task. The spec also says servers **MUST** authorize each task request — if your +server has auth, add that check in your route. + +
## Not implemented `tasks/update` (the client answering an `input_required` task) and `tasks/list`. The latter is absent from the spec on purpose — without sessions a server cannot scope a list to one caller -without leaking that other people's tasks exist. `tasks/update` would follow the same shape as the -other two: write the client's answer into the record, and let the handler read it at a step -boundary, exactly as it reads the cancelled status today. +without leaking that other people's tasks exist. The `ext-tasks` repo labels itself experimental and its schema is a draft, so these wire shapes may change before Tasks lands in core. diff --git a/packages/mcp-tasks/src/backends/qstash.ts b/packages/mcp-tasks/src/backends/qstash.ts index 0934e98..7d4eb45 100644 --- a/packages/mcp-tasks/src/backends/qstash.ts +++ b/packages/mcp-tasks/src/backends/qstash.ts @@ -43,8 +43,7 @@ export const DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"; * * **QStash caps this per plan** — the local dev server and the free tier reject anything above 5 * with `quota maxRetries exceeded`, so 5 is the highest value that works everywhere and the budget - * is bought with {@link DEFAULT_RETRY_DELAY} instead. Raise it if your plan allows; Vercel's own - * QStash-backed Workflow world defaults to 47. + * is bought with {@link DEFAULT_RETRY_DELAY} instead. Raise it if your plan allows. */ export const DEFAULT_RETRIES = 5; From 1df099ac96a47f0df56cd3f4b023f05c68a2b436 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 17:32:26 +0300 Subject: [PATCH 07/11] docs(mcp-tasks): keep the first snippet to required options only 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 --- packages/mcp-tasks/README.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 8a961d0..a0e17f4 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -39,16 +39,38 @@ export function createServer() { server, "generate_report", { description: "Generates a report", inputSchema: z.object({ topic: z.string() }) }, - async ({ topic }, task) => { - await task.update(`Researching ${topic}`); - return { content: [{ type: "text", text: `Report on ${topic}` }] }; - }, + async ({ topic }) => ({ content: [{ type: "text", text: await writeReport(topic) }] }), ); return server; } ``` +Everything above is required. Progress messages and cancellation are opt-in: + +
+Reporting progress and honouring cancellation + +The handler's second argument is the task. Both calls are optional — a handler that ignores them +still works, it just reports nothing and cannot be stopped early. + +```ts +async ({ topic }, task) => { + for (const source of sources) { + if (await task.isCancelled()) return {}; + await task.update(`Reading ${source}`); + await read(source); + } + return { content: [{ type: "text", text: await writeReport(topic) }] }; +}; +``` + +`task.update(...)` is what the client sees as `statusMessage` on its next poll. Cancellation is +cooperative: `tasks/cancel` flips the record and stops a pending delivery, but running code only +stops where it checks. + +
+ Then two routes — the MCP endpoint, and the one the work is delivered to: ```ts From 4df5534b2b804e48e2150d749229f200abaa54d1 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 17:45:55 +0300 Subject: [PATCH 08/11] docs(mcp-tasks): stop describing the execute endpoint in QStash's terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/mcp-tasks/README.md | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index a0e17f4..24b8584 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -218,8 +218,8 @@ sequenceDiagram participant H as Your handler participant St as TaskStore - D->>E: deliver { taskId } (signed) - E->>E: verify signature — 401 if bad + D->>E: deliver the task (authenticated by the transport) + E->>E: reject if it does not authenticate E->>S: executeTask(taskId) S->>St: get(taskId) S->>S: already terminal? → stop (redelivery guard) @@ -339,17 +339,25 @@ neither is durable, which is exactly the failure this package is about.
What does the execute endpoint actually do? -Everything that has to be right there belongs to the transport, which is why the dispatcher hands -you the endpoint instead of a checklist: - -- **Verifies the signature.** Against the URL you published to, not `request.url` — behind a proxy - the incoming URL is the internal one while QStash signed the public destination. Skipping this - would let anyone who can reach the route run tasks; here you cannot skip it. -- **Tells a delivery from a failure callback.** Both arrive at this one route; the failure callback - carries `sourceBody` and fires only once every retry is exhausted. -- **Picks the status code**, which is the retry contract: **200** ran or already terminal, **500** - the handler threw and the transport should try again, **401** bad signature and **400** an - unusable body — both terminal, because a retry cannot fix either. +Whatever its transport needs — which is the reason the dispatcher hands you a finished endpoint +instead of a checklist. Authenticating a delivery, recognising its shapes and answering in the +codes it understands are all facts about the transport, not about your application. So the answer +differs by dispatcher: + +**`QStashDispatcher`** serves the route itself. It authenticates each delivery by verifying the +QStash signature — against the URL you published to rather than `request.url`, since behind a proxy +the incoming URL is the internal one while QStash signed the public destination. It tells a normal +delivery (`{ taskId }`) from a failure callback (carries `sourceBody`, fires only once every retry +is exhausted). And it picks the status code, which *is* the retry contract: **200** ran or already +terminal, **500** the handler threw so try again, **401** bad signature and **400** an unusable +body — both terminal, because a retry cannot fix either. + +**`WorkflowDispatcher`** returns the Workflow engine's own `serve()` handler. Authentication, +replay and step journaling are the engine's, so there is nothing here to get wrong by hand; it adds +only the failure hook that settles the task once a run has exhausted its retries. + +A dispatcher that runs work in-process — `InlineTaskDispatcher` — has no endpoint at all, and +`createExecuteHandler()` throws to say so.
From 5d228fd0a096e1679b51cab8e62404d6bd3a29bc Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 18:00:16 +0300 Subject: [PATCH 09/11] docs(mcp-tasks): add 'Could this be part of the SDK?', linked from the intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/mcp-tasks/README.md | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 24b8584..46b488f 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -9,6 +9,10 @@ accepted the call. > The official SDK v2 ships the 2026-07-28 wire schemas for tasks but no runtime behind them — the > v1 experimental task APIs were removed with no migration path. This is that runtime. +> +> **Wondering what of this belongs in the SDK itself?** See +> [Could this be part of the SDK?](#could-this-be-part-of-the-sdk) — two things only the SDK can +> fix, and the one design choice that decides whether a built-in runtime survives serverless. ## Install @@ -264,6 +268,75 @@ checks. +## Could this be part of the SDK? + +Most of it need not be. This package is additive over `@modelcontextprotocol/server` — no fork, no +patches — which is itself the useful finding: a tasks runtime can live outside the SDK. Two things +cannot, and one design choice would decide whether a built-in runtime works on serverless at all. + +### Two things only the SDK can fix + +**1. `tasks/get` and `tasks/cancel` are undispatchable on the 2026-07-28 era.** They sit in the +SDK's 2025 method registry and were dropped from the 2026 one, so `isSpecRequestMethod` returns +true, the request is era-gated, and the gate answers `-32601` **before your handler is looked up**. +A `fallbackRequestHandler` does not help; the gate returns first. + +That leaves two workarounds, both bad: + +- Serve through `WebStandardStreamableHTTPServerTransport`, which stays on the 2025 era where the + methods still dispatch. This is what this package does by default — but it means serving a + 2026-era extension off the legacy codec, and it rules out `createMcpHandler`, and with it + [`mcp-handler`](https://www.npmjs.com/package/mcp-handler), the usual way to run MCP on Next.js. +- Rename the methods (`methods: { get: "upstash/tasks.get" }`). Anything outside both registries is + treated as a consumer-owned extension method and dispatches unconditionally — but they are no + longer the spec's wire names, so a conforming client calls `tasks/get`, receives `-32601`, and + can never poll a task it was just handed a valid id for. + +Either the 2026 registry should carry the task methods, or extension-owned methods should be able +to claim names the registries have released. + +**2. A tool callback cannot return a JSON-RPC error.** `McpServer` catches everything a tool +callback throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and +flattens it into `{ content, isError: true }`, dropping the code. The spec says a server must not +hand a task to a client that did not declare the capability, and `-32021` is the signal for it; as +things stand that code cannot reach the client. This package answers with a structured tool error +carrying the code in `structuredContent`, which is a workaround, not the contract. + +### One design choice, if the SDK does ship a runtime + +**Two interfaces, not one.** A durable task id does not make the underlying work durable, and those +are separate problems: + +```ts +interface TaskStore { + create(task): Promise; // must commit before tools/call replies + get(taskId): Promise; + update(taskId, patch): Promise; + settle(taskId, patch): Promise; // atomic, first terminal write wins +} + +interface TaskDispatcher { + dispatch(taskId): Promise; // hand the work to something that will run it + cancel(dispatchId): Promise; +} +``` + +The store half already has precedent: the C# SDK ships `IMcpTaskStore` and its docs are explicit +that the record must be reachable from any instance. The dispatcher half exists nowhere. Across the +official SDKs, execution is always in-process — `Task.Run` in C#, `tokio::spawn` in Rust, the +caller's own `.subscribe()` in Java's open PR, and Python's PR awaits the tool inline. The result is +the same everywhere: **a durable record and non-durable work.** + +That is survivable on a host that can keep a process alive. It is not survivable on serverless, +where the invocation ends with the response — which is where a large share of MCP servers run. With +a dispatcher seam, the same runtime supports both: ship an in-process dispatcher as the default so +nothing changes for people who do not need one, and let anyone else supply a queue, a workflow +engine, or a platform primitive like a Durable Object alarm. + +Optionally, a third method earns its place: letting the dispatcher supply its own delivery endpoint +(`createExecuteHandler()`), so authenticating a callback and choosing retry status codes stay +inside the transport that understands them instead of becoming the application's problem. + ## Reference
From e157f12e407bae14df448a852be0eae06512f156 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 18:03:00 +0300 Subject: [PATCH 10/11] docs(mcp-tasks): name the TypeScript SDK explicitly, and argue for owning the callback endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- packages/mcp-tasks/README.md | 60 +++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index 46b488f..d814c91 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -7,12 +7,14 @@ A long-running tool answers with a task handle instead of blocking. The task rec Upstash Redis; the work runs through QStash or Upstash Workflow, so it survives the process that accepted the call. -> The official SDK v2 ships the 2026-07-28 wire schemas for tasks but no runtime behind them — the -> v1 experimental task APIs were removed with no migration path. This is that runtime. +> `@modelcontextprotocol/server` v2 ships the 2026-07-28 wire schemas for tasks but no runtime +> behind them — the v1 experimental task APIs were removed with no migration path. This is that +> runtime. > -> **Wondering what of this belongs in the SDK itself?** See -> [Could this be part of the SDK?](#could-this-be-part-of-the-sdk) — two things only the SDK can -> fix, and the one design choice that decides whether a built-in runtime survives serverless. +> **Wondering what of this belongs in `@modelcontextprotocol/server` itself?** See +> [Could this be part of the TypeScript SDK?](#could-this-be-part-of-the-typescript-sdk) — two +> things only that SDK can fix, and the one design choice that decides whether a built-in runtime +> survives serverless. ## Install @@ -268,16 +270,20 @@ checks.
-## Could this be part of the SDK? +## Could this be part of the TypeScript SDK? Most of it need not be. This package is additive over `@modelcontextprotocol/server` — no fork, no -patches — which is itself the useful finding: a tasks runtime can live outside the SDK. Two things -cannot, and one design choice would decide whether a built-in runtime works on serverless at all. +patches — which is itself the useful finding: a tasks runtime can live outside that package. Two +things cannot, and one design choice would decide whether a built-in runtime works on serverless at +all. -### Two things only the SDK can fix +Everything below was verified against `@modelcontextprotocol/server@2.0.0` and `main` as of +2026-09. -**1. `tasks/get` and `tasks/cancel` are undispatchable on the 2026-07-28 era.** They sit in the -SDK's 2025 method registry and were dropped from the 2026 one, so `isSpecRequestMethod` returns +### Two things only `@modelcontextprotocol/server` can fix + +**1. `tasks/get` and `tasks/cancel` are undispatchable on the 2026-07-28 era.** They sit in that +package's 2025 method registry and were dropped from the 2026 one, so `isSpecRequestMethod` returns true, the request is era-gated, and the gate answers `-32601` **before your handler is looked up**. A `fallbackRequestHandler` does not help; the gate returns first. @@ -302,7 +308,7 @@ hand a task to a client that did not declare the capability, and `-32021` is the things stand that code cannot reach the client. This package answers with a structured tool error carrying the code in `structuredContent`, which is a workaround, not the contract. -### One design choice, if the SDK does ship a runtime +### One design choice, if the TypeScript SDK does ship a runtime **Two interfaces, not one.** A durable task id does not make the underlying work durable, and those are separate problems: @@ -333,9 +339,26 @@ a dispatcher seam, the same runtime supports both: ship an in-process dispatcher nothing changes for people who do not need one, and let anyone else supply a queue, a workflow engine, or a platform primitive like a Durable Object alarm. -Optionally, a third method earns its place: letting the dispatcher supply its own delivery endpoint -(`createExecuteHandler()`), so authenticating a callback and choosing retry status codes stay -inside the transport that understands them instead of becoming the application's problem. +**And the callback endpoint should belong to the runtime, not the application.** This is the part +that surprised us most in practice. Once the work runs outside the request, something has to call +*back in* to run it — so a task server needs a second route that has nothing to do with MCP. The +spec describes the client↔server task methods and says nothing about this one, so every serverless +implementation invents its own, and each one re-implements the same delicate things: authenticating +the caller, telling a delivery from a failure notification, and choosing the status code that +decides whether the transport tries again. Forget the first and anyone who can reach the route can +run your tasks. + +None of that is the application's knowledge — it is the transport's. So the dispatcher should hand +back a finished endpoint: + +```ts +// the entire second route +export const POST = tasks.createExecuteHandler(); +``` + +That also keeps the door open to not having a second route at all: because the transport +authenticates its own deliveries, the same handler can sit behind the MCP endpoint and be selected +on the way in, so a server can stay single-endpoint if it wants to. ## Reference @@ -398,7 +421,7 @@ neither is durable, which is exactly the failure this package is about. | --- | --- | | `createTaskLayer(options)` | `{ registerTask, executeTask, failTask, createExecuteHandler, getTask, store, dispatcher }` | | `TaskStore`, `TaskDispatcher`, `TaskContext` | The two seams, and what a handler is handed | -| `TaskEndpoints`, `TaskJournal` | What a dispatcher calls back into, and how it journals the SDK's writes | +| `TaskEndpoints`, `TaskJournal` | What a dispatcher calls back into, and how it journals this package's own writes | | `Task`, `WireTask`, `TaskStatus`, `TaskError` | The record, and the subset that goes on the wire | | `isTerminal`, `TERMINAL_STATUSES`, `UnknownTaskError` | Status helpers and the store's error type | | `TASKS_EXTENSION`, `TASKS_PROTOCOL_VERSION`, `TASK_METHODS` | The extension id, `"2026-07-28"`, the method names | @@ -463,9 +486,10 @@ names, so a client has to know yours.
-Why the transport instead of the SDK's createMcpHandler? +Why the transport instead of createMcpHandler? -Same reason. `tasks/get` and `tasks/cancel` sit in the SDK's **2025** method registry and were +Same reason. `tasks/get` and `tasks/cancel` sit in `@modelcontextprotocol/server`'s **2025** +method registry and were dropped from the **2026** one, so on the modern era they are neither dispatchable nor treated as free-form extension methods — the gate returns `-32601` before your handler runs. Serving through `WebStandardStreamableHTTPServerTransport` leaves the instance on the 2025 era, where they dispatch From 55f37f18006ffeeaeac7c6dac1bb95396864b2c5 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Tue, 8 Sep 2026 18:05:32 +0300 Subject: [PATCH 11/11] docs(mcp-tasks): make the callback endpoint a third gap, and de-emphasise the shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/mcp-tasks/README.md | 79 +++++++++++++++++------------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/packages/mcp-tasks/README.md b/packages/mcp-tasks/README.md index d814c91..5447801 100644 --- a/packages/mcp-tasks/README.md +++ b/packages/mcp-tasks/README.md @@ -12,9 +12,8 @@ accepted the call. > runtime. > > **Wondering what of this belongs in `@modelcontextprotocol/server` itself?** See -> [Could this be part of the TypeScript SDK?](#could-this-be-part-of-the-typescript-sdk) — two -> things only that SDK can fix, and the one design choice that decides whether a built-in runtime -> survives serverless. +> [Could this be part of the TypeScript SDK?](#could-this-be-part-of-the-typescript-sdk) — three +> gaps worth closing upstream, two of which no library can work around. ## Install @@ -273,14 +272,17 @@ checks. ## Could this be part of the TypeScript SDK? Most of it need not be. This package is additive over `@modelcontextprotocol/server` — no fork, no -patches — which is itself the useful finding: a tasks runtime can live outside that package. Two -things cannot, and one design choice would decide whether a built-in runtime works on serverless at -all. +patches — which is itself the useful finding: a tasks runtime can live outside that package. Three +gaps are worth closing upstream anyway. Everything below was verified against `@modelcontextprotocol/server@2.0.0` and `main` as of 2026-09. -### Two things only `@modelcontextprotocol/server` can fix +### Three gaps + +The first two are blockers: no library can work around them. The third is not — this package +implements it — but every task server has to, and getting it wrong is a security bug rather than a +missing feature. **1. `tasks/get` and `tasks/cancel` are undispatchable on the 2026-07-28 era.** They sit in that package's 2025 method registry and were dropped from the 2026 one, so `isSpecRequestMethod` returns @@ -308,10 +310,30 @@ hand a task to a client that did not declare the capability, and `-32021` is the things stand that code cannot reach the client. This package answers with a structured tool error carrying the code in `structuredContent`, which is a workaround, not the contract. -### One design choice, if the TypeScript SDK does ship a runtime +**3. The callback endpoint has no home.** Once work runs outside the request, something has to call +*back in* to run it, so a task server needs a second route the spec never describes. Every +implementation invents its own, and each re-implements the same delicate parts: authenticating the +caller, telling a delivery from a failure notification, and picking the status code that decides +whether the transport retries. Miss the first and anyone who can reach the route can run your +tasks. + +None of that is application knowledge — it belongs to whatever transport is driving the work. Given +a dispatcher seam it collapses to one line, and it need not even be a second route: because the +transport authenticates its own deliveries, the same handler can sit behind the MCP endpoint. + +```ts +export const POST = tasks.createExecuteHandler(); // the entire second route +``` + +### And, less urgently, a shape + +The three above are gaps. This is only a suggestion, for whenever a runtime does land. -**Two interfaces, not one.** A durable task id does not make the underlying work durable, and those -are separate problems: +
+The shape that survives serverless + +**Two interfaces, not one** — a durable task id does not make the underlying work durable, and +those are separate problems: ```ts interface TaskStore { @@ -327,38 +349,13 @@ interface TaskDispatcher { } ``` -The store half already has precedent: the C# SDK ships `IMcpTaskStore` and its docs are explicit -that the record must be reachable from any instance. The dispatcher half exists nowhere. Across the -official SDKs, execution is always in-process — `Task.Run` in C#, `tokio::spawn` in Rust, the -caller's own `.subscribe()` in Java's open PR, and Python's PR awaits the tool inline. The result is -the same everywhere: **a durable record and non-durable work.** - -That is survivable on a host that can keep a process alive. It is not survivable on serverless, -where the invocation ends with the response — which is where a large share of MCP servers run. With -a dispatcher seam, the same runtime supports both: ship an in-process dispatcher as the default so -nothing changes for people who do not need one, and let anyone else supply a queue, a workflow -engine, or a platform primitive like a Durable Object alarm. - -**And the callback endpoint should belong to the runtime, not the application.** This is the part -that surprised us most in practice. Once the work runs outside the request, something has to call -*back in* to run it — so a task server needs a second route that has nothing to do with MCP. The -spec describes the client↔server task methods and says nothing about this one, so every serverless -implementation invents its own, and each one re-implements the same delicate things: authenticating -the caller, telling a delivery from a failure notification, and choosing the status code that -decides whether the transport tries again. Forget the first and anyone who can reach the route can -run your tasks. - -None of that is the application's knowledge — it is the transport's. So the dispatcher should hand -back a finished endpoint: +The store half has precedent — the C# SDK ships `IMcpTaskStore`. The dispatcher half exists in no +official SDK: execution is in-process everywhere (`Task.Run`, `tokio::spawn`, `.subscribe()`, +Python's PR awaits the tool inline), which leaves a durable record and non-durable work. Fine on a +host that keeps a process alive; not on serverless. An in-process dispatcher as the default would +change nothing for anyone who does not need one. -```ts -// the entire second route -export const POST = tasks.createExecuteHandler(); -``` - -That also keeps the door open to not having a second route at all: because the transport -authenticates its own deliveries, the same handler can sit behind the MCP endpoint and be selected -on the way in, so a server can stay single-endpoint if it wants to. +
## Reference