diff --git a/.changeset/hot-jars-judge.md b/.changeset/hot-jars-judge.md new file mode 100644 index 0000000..c9c40e2 --- /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`. + +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/.changeset/olive-pans-shave.md b/.changeset/olive-pans-shave.md new file mode 100644 index 0000000..bcbf545 --- /dev/null +++ b/.changeset/olive-pans-shave.md @@ -0,0 +1,33 @@ +--- +"@upstash/mcp-tasks": minor +--- + +Add a Workflow dispatcher, and let each transport decide when a failure is final. + +`@upstash/mcp-tasks/upstash` now also exports `WorkflowDispatcher`, which runs each task as an +Upstash Workflow run — one invocation per step, with finished steps replayed from a journal. That +is the difference between surviving a crash and outliving a time limit: a QStash delivery is a +single serverless invocation, so exceeding the platform's function limit kills the work and the +redelivery restarts the handler from the beginning. + +The layer is now generic over what its transport provides. `createTaskLayer(...)` +gives handlers `TaskContext & WorkflowContext` — one object carrying both `update`/`isCancelled` +and the engine's real `run`, `sleep`, `call`, `waitForEvent` — while a queue-backed layer gives +just the `TaskContext`. Transports are not interchangeable, and the types now say so instead of +papering over it with a lowest-common-denominator shim. The SDK journals its own writes, so +`task.update(...)` is not repeated when a workflow replays the handler. + +`TaskStore.update` is now ignored once a task is terminal, on both backends. The spec's "state does +not change" covers the status message, and a progress write landing after a cancel was overwriting +"Cancelled by client". + +Retry bookkeeping moves out of the core. `executeTask` no longer takes `isFinalAttempt` and never +settles a task `failed`: it rethrows and leaves the task `working`, and the dispatcher calls the new +`failTask` once it has genuinely stopped retrying. QStash learns that from its own failure callback, +which fires only after every retry is exhausted and now arrives at the *same* execute endpoint — +one route, one signature check, told apart by the body. Workflow learns it from `failureFunction`. +Nothing in the package counts attempts or reads a retry header any more. + +Removed: `ExecuteTaskOptions`, `TaskRunner`, `isFinalQStashAttempt`, `QSTASH_RETRIED_HEADER`, and +the public `QStashDispatcher.retries` field. Added: `TaskEndpoints`, `TaskSteps`, +`TaskDispatcher.attach`, and `TaskLayer.failTask`; `createExecuteHandler` now takes no arguments. 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..1077e2a 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,83 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). `$and/$or/$must/$should/$mustNot`. Aggregations: `$terms`, `$stats`, `$sum`, `$avg`, `$min`, `$max`, `$count`, `$histogram`, `$percentiles`, `$cardinality`. +## MCP Tasks facts (`packages/mcp-tasks`) — IMPORTANT +Verified empirically against `@modelcontextprotocol/server@2.0.0`; don't re-derive them from the docs. +- **The SDK has schemas but no tasks runtime.** v2 ships `Task`/`GetTaskRequest`/`CreateTaskResult` + etc. and `isTaskAugmentedRequestParams`, but registers **no** `tasks/*` handler and has no store. + v1's experimental task APIs were removed with no migration path. That gap is what this package fills. +- **`createMcpHandler` cannot serve `tasks/get`/`tasks/cancel`.** It pins the request to the + 2026-07-28 era from the client's `_meta` protocol-version claim, and that era's dispatch gate + returns **`-32601` before the handler is looked up**: those strings are in the SDK's *2025* method + registry (so `isSpecRequestMethod` is true) and absent from the *2026* one. A `fallbackRequestHandler` + does not help — the gate returns first. Proven: the registered handler never runs, while a + namespaced `upstash/tasks.get` on the same server dispatches fine. + **So the demo and the docs use `WebStandardStreamableHTTPServerTransport` + `transport.handleRequest`**, + which leaves the instance on the 2025 era where `tasks/*` dispatch normally. `createTaskLayer`'s + `methods` option is the escape hatch for `createMcpHandler` users. +- **`supportedProtocolVersions: [TASKS_PROTOCOL_VERSION]` on the `McpServer` is required**, or the + transport rejects every 2026-07-28 request with "Unsupported protocol version" (its default list is + the 2025 era's). There is no *public* 2026 constant in the SDK — `SUPPORTED_PROTOCOL_VERSIONS` is + legacy-only and `LATEST_PROTOCOL_VERSION` is `"2025-11-25"`. +- **The per-request envelope works on both eras:** `ctx.mcpReq.envelope[CLIENT_CAPABILITIES_META_KEY]` + carries the lifted client capabilities. That is the capability check — there is no session to ask. +- **A tool callback cannot return a JSON-RPC error.** `McpServer` catches everything a tool callback + throws — `ProtocolError` and `MissingRequiredClientCapabilityError` included — and flattens it to + `{content, isError:true}`, **dropping the code**. So the missing-capability refusal is a structured + tool error with `structuredContent: { code: -32021, requiredCapabilities }`, not a thrown error. +- **`resultType: "task"` from `tools/call` is allowed** (`tools/call` is in the SDK's + `EXTENDED_RESULT_TYPE_METHODS`, forwarded verbatim). We return the task **flattened**, not under a + `task` key: `"task"` is a hard-coded "foreign family" key that blocks the SDK's contentless-result + default, so `{resultType:"task", task:{…}}` without `content` is rejected — the flattened form gets + `content: []` filled in automatically. +- **Design choices that differ from the naive version** (all covered by tests): + `TaskStore.settle` is a *guarded, atomic* terminal transition (a Lua script on Redis) so a client's + `tasks/cancel` and the executor completing cannot clobber each other — first terminal write wins; + the store keeps **one hash field per task property** (not one JSON blob) so a progress `update` and + a cancel never overwrite each other's fields; and `executeTask(id, {isFinalAttempt})` keeps a task + **`working`** until the dispatcher's last delivery, because settling `failed` on the first error + makes it terminal and every retry then no-ops on the redelivery guard. +- **Redis encoding:** every hash field is written `JSON.stringify`d and read back with **no decode of + our own** — `@upstash/redis` auto-`JSON.parse`s responses, so the single parse is the exact inverse. + Decoding again turns a `statusMessage` of `"123"` into the number `123` (this actually happened). +- **QStash retry budget must outlast a restart.** `Upstash-Retried` (count so far, from 0) is the only + retry header; there is no max-retries header, so `isFinalQStashAttempt(headers, dispatcher.retries)` + takes the configured max. With a flat `"1000"` delay a kill-9'd server exhausts all retries in ~10s + and the task is dead-lettered while still reading `working` — observed, then fixed, then re-verified + end to end (kill -9 mid-task → restart → QStash redelivery → `completed`). + **`retries` is plan-capped:** the local dev server and the free tier reject anything above **5** + with `quota maxRetries exceeded` (this bit a `DEFAULT_RETRIES = 12` attempt — the tool call comes + back as an `isError` result carrying that message, not as a thrown error). So the budget is bought + with backoff instead: `DEFAULT_RETRIES = 5` and + `DEFAULT_RETRY_DELAY = "min(pow(3, retried) * 1000, 300000)"` ≈ 2 minutes over five attempts. +- **The dispatcher owns its delivery endpoint** (`TaskDispatcher.createExecuteHandler?(run)`, surfaced + as `tasks.createExecuteHandler()`): signature verification, task-id parsing, attempt counting and + the retry status codes live in the transport, so an app route is one line and cannot forget + `Receiver.verify`. Modelled on Vercel Workflow's `Queue.createQueueHandler` (see below). Status + contract: **200** ack, **401** bad signature, **400** no task id (both terminal — a retry cannot fix + either), **500** only when the task threw and QStash still has attempts. Verification uses the + **published** `url`, not `request.url`, because behind a proxy the incoming URL is the internal one + while QStash signed the public destination. +- **Ecosystem context (verified 2026-09).** Keep two axes apart when reading this — *is there an + interface you can implement* is not *does Redis work today*, and the answers invert. + Among *official* MCP SDKs, only **C#** ships a store interface you can implement (`IMcpTaskStore`, + 7 methods) — but the only in-box implementation is `InMemoryMcpTaskStore`, so Redis is homework. + Rust's `TaskManager` is a concrete in-memory struct with no trait; Python/Java have store + interfaces only in unmerged PRs; Go/Kotlin/Swift/Ruby have none. + Unofficial **FastMCP** is the mirror image: no implementable seam (Docket is both queue and store, + and you pick a backend by URL scheme — `memory://` or `redis://`, nothing else), but Redis works + out of the box with one URL, and it is the only tasks implementation anywhere that makes the + *work* durable (Docket queue plus `worker_cli` workers out of process; the memory backend is + single-process). + **No official SDK in any language abstracts execution** — all of them `Task.Run`/`tokio::spawn`/ + `.subscribe()` in-process, i.e. durable record, non-durable work. So this package's + `TaskStore` + `TaskDispatcher` split is not a port of prior MCP art — the closest analogue is + Vercel Workflow's `World = Storage + Queue + Streamer`. +- Tests: `src/core.test.ts` drives a real `McpServer` + real transport over genuine JSON-RPC; + `src/upstash.test.ts` hits real Redis. Both run under the root vitest config. +- **Local dev needs the QStash dev server** (`npx @upstash/qstash-cli dev`) — it prints deterministic + creds. `APP_URL` must be reachable *from QStash*. + ## Eve framework facts - The repo is on **`eve@0.47.3`** everywhere (`packages/eve`, `packages/eve-extension`, `examples/eve-demo`, `examples/eve-extension-demo`). `packages/eve`'s peer stays 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..a8b5841 --- /dev/null +++ b/examples/mcp-tasks-demo/.env.example @@ -0,0 +1,22 @@ +# Upstash Redis — the durable task record. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# QStash — the durable execution transport. +# +# For local development run `npx @upstash/qstash-cli dev` in another terminal. It prints a URL, a +# token and both signing keys; paste them here. For a deployed app use the real values from the +# Upstash console instead and drop QSTASH_URL. +QSTASH_URL=http://127.0.0.1:8080 +QSTASH_TOKEN= +QSTASH_CURRENT_SIGNING_KEY= +QSTASH_NEXT_SIGNING_KEY= + +# Where QStash delivers a task. Must be reachable *from QStash*: the local dev server can reach +# 127.0.0.1, the hosted service cannot — use your deployment URL or a tunnel there. +APP_URL=http://127.0.0.1:3000 + +# Which transport runs the work. Both serve the same /api/execute route. +# qstash (default) one delivery, one invocation — bounded by the function limit +# workflow one invocation per step — can outlive the function limit +TASKS_DRIVER=qstash 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..7cfb132 --- /dev/null +++ b/examples/mcp-tasks-demo/README.md @@ -0,0 +1,99 @@ +# 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: 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 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 | + +## 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 +``` + +## 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 +`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'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 + +- 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-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 new file mode 100644 index 0000000..cd63d1d --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/execute/route.ts @@ -0,0 +1,15 @@ +/** + * Where QStash delivers a task — and, once retries are exhausted, its failure callback. + * + * 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/qstash-server"; + +export const dynamic = "force-dynamic"; +// 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 new file mode 100644 index 0000000..3dd8800 --- /dev/null +++ b/examples/mcp-tasks-demo/app/api/mcp/route.ts @@ -0,0 +1,16 @@ +/** + * 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`. + */ +import { createServer } from "../../lib/qstash-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/globals.css b/examples/mcp-tasks-demo/app/globals.css new file mode 100644 index 0000000..20c3add --- /dev/null +++ b/examples/mcp-tasks-demo/app/globals.css @@ -0,0 +1,423 @@ +: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; +} + +.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/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..026d7b1 --- /dev/null +++ b/examples/mcp-tasks-demo/app/lib/mcp-client.ts @@ -0,0 +1,158 @@ +/** + * 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"; +/** 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"; + +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; + /** Which of the two servers to talk to. Defaults to the QStash one. */ + server?: ServerKey; +}; + +/** + * 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(SERVERS[options.server ?? "qstash"].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/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/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 new file mode 100644 index 0000000..60f0800 --- /dev/null +++ b/examples/mcp-tasks-demo/app/page.tsx @@ -0,0 +1,311 @@ +"use client"; + +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; + lastPolledAt: number; + polls: number; + wire: WireTask; +}; + +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); + 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, server }) + .then(result => setTools(result.tools.map(tool => tool.name))) + .catch(cause => setError(String(cause))); + }, [onFrame, server]); + + // 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, task.server); + } + }, 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, from: ServerKey) { + try { + 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, + ), + ); + } 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, server }, + ); + setTasks(previous => [ + { + server, + 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, from: ServerKey) { + try { + await rpc("tasks/cancel", { taskId }, { onFrame, server: from }); + await poll(taskId, from); + } 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

+
+
+ {(Object.keys(SERVERS) as ServerKey[]).map(key => ( + + ))} +
+
+ 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, task.server)} + /> + )) + )} +
+ +
+

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} + {SERVERS[task.server].label} + {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..6a4e285 --- /dev/null +++ b/examples/mcp-tasks-demo/package.json @@ -0,0 +1,29 @@ +{ + "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", + "@upstash/workflow": "^1.3.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..7a0d4eb --- /dev/null +++ b/examples/mcp-tasks-demo/scripts/smoke.mjs @@ -0,0 +1,101 @@ +// 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)); + +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}${ENDPOINT}`, { + 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 (${ENDPOINT}) ==`); +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..5447801 --- /dev/null +++ b/packages/mcp-tasks/README.md @@ -0,0 +1,549 @@ +# @upstash/mcp-tasks + +A durable [MCP Tasks](https://github.com/modelcontextprotocol/ext-tasks) runtime for the official +TypeScript SDK. + +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. + +> `@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 `@modelcontextprotocol/server` itself?** See +> [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 + +```bash +npm install @upstash/mcp-tasks @modelcontextprotocol/server @upstash/redis @upstash/qstash +``` + +## Usage + +```ts +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"; + +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" }, + { supportedProtocolVersions: [TASKS_PROTOCOL_VERSION] }, + ); + + tasks.registerTask( + server, + "generate_report", + { description: "Generates a report", inputSchema: z.object({ topic: z.string() }) }, + 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 +// 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 route is deliberately not yours to write — the dispatcher owns it. See the +[FAQ](#faq) for what it does. + +## Choosing a dispatcher + +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 { RedisTaskStore, WorkflowDispatcher } from "@upstash/mcp-tasks/upstash"; +import type { WorkflowContext } from "@upstash/workflow"; + +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", "ttlMs": 300000, "pollIntervalMs": 2000 } + +// tasks/get → progress, then the result inline +{ "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. + +## How it fits together + +
+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 +``` + +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 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) + S->>H: run(args, task) + H->>St: update(statusMessage) via task.update + H-->>S: result + S->>St: settle(completed, result) + E-->>D: 200 +``` + +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. + +
+ +## 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. Three +gaps are worth closing upstream anyway. + +Everything below was verified against `@modelcontextprotocol/server@2.0.0` and `main` as of +2026-09. + +### 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 +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. + +**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. + +
+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 { + 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 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. + +
+ +## 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; +} +``` + +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 + +**`createTaskLayer`** + +| | | +| --- | --- | +| `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`. + +**`WorkflowDispatcher`** — `url` required; `client`, `headers`, `retries`. + +
+ +
+Exports + +| Export | What it is | +| --- | --- | +| `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 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 | +| `MemoryTaskStore`, `InlineTaskDispatcher` | Non-durable backends for tests | +| `@upstash/mcp-tasks/upstash` | `RedisTaskStore`, `QStashDispatcher`, `WorkflowDispatcher` | + +
+ +## FAQ + +
+What does the execute endpoint actually do? + +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. + +
+ +
+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 createMcpHandler? + +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 +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. + +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..fbc82b6 --- /dev/null +++ b/packages/mcp-tasks/package.json @@ -0,0 +1,79 @@ +{ + "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", + "@upstash/workflow": ">=1.3.0" + }, + "peerDependenciesMeta": { + "@upstash/qstash": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@upstash/workflow": { + "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", + "@upstash/workflow": "^1.3.3" + } +} diff --git a/packages/mcp-tasks/src/backends/memory.ts b/packages/mcp-tasks/src/backends/memory.ts new file mode 100644 index 0000000..5ed7677 --- /dev/null +++ b/packages/mcp-tasks/src/backends/memory.ts @@ -0,0 +1,136 @@ +/** + * 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 TaskEndpoints, + 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); + // 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 }; + } + + 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>(); + private endpoints: TaskEndpoints | undefined; + private readonly autoRun: boolean; + + /** How many tasks have been dispatched. Test-only. */ + dispatched = 0; + + 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(() => endpoints.run(taskId, undefined)) + .then( + () => undefined, + // 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)); + 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/backends/qstash.test.ts b/packages/mcp-tasks/src/backends/qstash.test.ts new file mode 100644 index 0000000..fc875bd --- /dev/null +++ b/packages/mcp-tasks/src/backends/qstash.test.ts @@ -0,0 +1,323 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +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(); + 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("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, + ); + 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("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"]; + + 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", + receiver: receiver(accept), + }); + dispatcher.attach({ + run: async (taskId: string) => { + calls.ran.push(taskId); + if (throws) throw new Error("boom"); + }, + fail: async (taskId: string, error: TaskError) => { + calls.failed.push({ taskId, error }); + }, + }); + return { handler: dispatcher.createExecuteHandler(), calls }; + }; + + const deliver = (body: unknown) => + new Request("https://internal.example/api/execute", { + method: "POST", + headers: { "upstash-signature": "sig" }, + body: JSON.stringify(body), + }); + + /** 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" })); + + expect(response.status).toBe(200); + expect(calls.ran).toEqual(["t1"]); + expect(calls.failed).toEqual([]); + }); + + it("answers 500 so QStash retries, without failing the task", async () => { + const { handler, calls } = attached({ throws: true }); + const response = await handler(deliver({ taskId: "t1" })); + + expect(response.status).toBe(500); + // The transport has attempts left; nothing here decides the task has failed. + expect(calls.failed).toEqual([]); + }); + + 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", + }); + }); + + it("rejects an unsigned delivery with 401 and never runs the task", async () => { + 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(calls.ran).toEqual([]); + expect(calls.failed).toEqual([]); + }); + + 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( + ( + 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 dispatcher = new QStashDispatcher({ + url: "https://public.example.com/api/execute", + receiver: spy, + }); + dispatcher.attach({ run: async () => undefined, fail: async () => undefined }); + + await dispatcher.createExecuteHandler()(deliver({ taskId: "t1" })); + expect(urls).toEqual(["https://public.example.com/api/execute"]); + }); + + 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/backends/qstash.ts b/packages/mcp-tasks/src/backends/qstash.ts new file mode 100644 index 0000000..7d4eb45 --- /dev/null +++ b/packages/mcp-tasks/src/backends/qstash.ts @@ -0,0 +1,504 @@ +/** + * 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. + */ +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/backends/workflow.test.ts b/packages/mcp-tasks/src/backends/workflow.test.ts new file mode 100644 index 0000000..1b0e5f4 --- /dev/null +++ b/packages/mcp-tasks/src/backends/workflow.test.ts @@ -0,0 +1,209 @@ +/** + * 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 } 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("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 tasks = createTaskLayer({ + store, + dispatcher: { dispatch: async () => undefined, cancel: async () => undefined }, + }); + + const now = new Date().toISOString(); + await store.create({ + taskId: "t1", + 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) => await handler(task), + ); + + return await tasks.executeTask("t1", context); + } + + 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({ taskId: "t1" }); + }); + + it("merges the transport's context in, keeping its prototype methods", async () => { + const workflow = new FakeWorkflowContext(); + + 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 }; + }); + + 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("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, + dispatcher: { dispatch: async () => undefined, cancel: async () => undefined }, + }); + const now = new Date().toISOString(); + await store.create({ + taskId: "t1", + 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) => { + await task.update("one"); + await task.update("two"); + return {}; + }, + ); + + 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"); + }); + + 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/backends/workflow.ts b/packages/mcp-tasks/src/backends/workflow.ts new file mode 100644 index 0000000..6830a81 --- /dev/null +++ b/packages/mcp-tasks/src/backends/workflow.ts @@ -0,0 +1,167 @@ +/** + * 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, TaskJournal } 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; + // 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 }) => { + 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; + } +} + +/** + * Lets the core journal its own writes, so `task.update(...)` is not repeated on every replay. + * + * 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 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 { + 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/src/core.test.ts b/packages/mcp-tasks/src/core.test.ts new file mode 100644 index 0000000..dd572a0 --- /dev/null +++ b/packages/mcp-tasks/src/core.test.ts @@ -0,0 +1,405 @@ +/** + * 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 "./backends/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>, + layer: Partial[0]> = {}, +): Promise { + const store = new MemoryTaskStore(); + // `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. + 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("leaves a thrown task retryable rather than settling it failed", async () => { + let attempts = 0; + live = await harness( + async () => { + attempts += 1; + if (attempts < 3) throw new Error(`boom ${attempts}`); + return { content: [{ type: "text", text: "eventually" }] }; + }, + { dispatcher: new InlineTaskDispatcher({ autoRun: false }) }, + ); + const created = await live.rpc("tools/call", { + name: "generate_report", + arguments: { topic: "x" }, + }); + const taskId = String(created.result?.taskId); + + // 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)).rejects.toThrow("boom 2"); + expect((await live.rpc("tasks/get", { taskId })).result?.status).toBe("working"); + + 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 once the dispatcher stops retrying", 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..66c34dc --- /dev/null +++ b/packages/mcp-tasks/src/core.ts @@ -0,0 +1,485 @@ +/** + * 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 TaskError, + type TaskJournal, + 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; + +/** + * 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 = { + /** Registers a tool whose calls are answered with a task handle. */ + registerTask( + server: McpServer, + name: string, + config: TaskToolConfig, + handler: TaskHandler, TContext>, + ): void; + /** + * 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, context?: TContext, journal?: TaskJournal): 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: + * + * ```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. */ + 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, TContext>, + ): 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, + 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 + // 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.`, + ); + } + + // 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) => { + 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"; + }, + }; + + // 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 { + 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, + mergeContext( + { taskId: "", update: async () => undefined, isCancelled: async () => false }, + undefined, + ), + ); + } + + 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; + } + + 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(); + } + + // 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, + 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); +} + +/** + * 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; + return wire; +} diff --git a/packages/mcp-tasks/src/index.ts b/packages/mcp-tasks/src/index.ts new file mode 100644 index 0000000..d9d08bb --- /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 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 TaskEndpoints, + type TaskStatus, + type TaskStore, + type TerminalTaskPatch, + type TerminalTaskStatus, + type WireTask, +} from "./types.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/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..341d065 --- /dev/null +++ b/packages/mcp-tasks/src/types.ts @@ -0,0 +1,223 @@ +/** + * 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. + * + * **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; + + /** + * 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. + * + * `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 { + /** + * 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; + + /** + * 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, 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?(): (request: Request) => Promise; +} + +/** The layer's entry points, handed to a dispatcher by {@link TaskDispatcher.attach}. */ +export type TaskEndpoints = { + /** + * 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, context: TContext, journal?: TaskJournal): 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; +}; + +/** + * How a transport journals a side effect so it runs once across replays. + * + * 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 TaskJournal = (name: string, fn: () => Promise) => Promise; + +/** + * 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; + /** + * 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.ts b/packages/mcp-tasks/src/upstash.ts new file mode 100644 index 0000000..3731c26 --- /dev/null +++ b/packages/mcp-tasks/src/upstash.ts @@ -0,0 +1,22 @@ +/** + * The Upstash backends, in one place: Redis for the task record, and either QStash or Upstash + * Workflow for the execution. + * + * The two dispatchers are not interchangeable, and the type system says so — see + * {@link QStashDispatcher} and {@link WorkflowDispatcher} for which to pick. + * + * 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/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..e8a4a1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,49 @@ 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 + '@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) + 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 +352,31 @@ 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 + '@upstash/workflow': + specifier: ^1.3.3 + version: 1.3.3(zod@4.4.3) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + packages/sdk: dependencies: '@upstash/ratelimit': @@ -987,6 +1055,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 +2853,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: @@ -2781,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==} @@ -3048,6 +3136,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 +3513,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 +3826,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 +4258,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 +5815,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 +7462,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 @@ -7349,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 @@ -7590,6 +7723,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 +8217,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 +8579,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.10: {} + joycon@3.1.1: {} js-yaml@3.14.2: @@ -9069,6 +9210,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)), "..");